mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
c0329d36dc75dd90c52ef1691f7f26ccb3bf0937
3362 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c0329d36dc |
Merge remote-tracking branch 'origin/main' into honor/nip-fi-upgrade-pod
* origin/main: fix(relay): set received_at in the system-channel fan-out test helper (#9214) feat(mobile): move Activity to a Home bell and align Home spacing (#9212) Allow operators to set initial NIP-FI policy on create (#9211) perf(loadgen): spread seed writes across channel creators (#9206) feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170) Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
6c3ffd160b |
fix(relay): set received_at in the system-channel fan-out test helper (#9214)
## Summary `buzz-relay` lib tests fail to compile on `main`: `error[E0063]: missing field received_at in initializer of buzz_pubsub::ChannelEvent` at `crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs:1549`. This is a semantic merge conflict. #9195 added `ChannelEvent::received_at`, and #8519 merged a minute later with a test helper that builds a `ChannelEvent` without it. Neither PR's CI saw the other. This sets the field to `tokio::time::Instant::now()`, as every other initializer does. It breaks clippy (`Rust Lint`) and the backend-integration test archive for every open PR, because CI builds the merge with `main`. ### Testing - `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -D warnings` on a cloud workstation at this head. ## What got simpler Nothing; a one-field compile fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
beec055300 |
feat(mobile): move Activity to a Home bell and align Home spacing (#9212)
The UI-only part of #9193 by @klopez4212, split out so it can land without the Activity sync changes. - Activity moves from a row in the Home list to a bell button in the Home header, with an unread dot driven by the existing Activity feed and read state. - Home filter capsules match native iOS and Android widths, with a subtle outline on Android when the header collapses. - Home section spacing is tighter. Left out: the reconnect rewrite in #9193's last commit (live events applied in place, no rebuild on reconnect, fatal fallback reads). The Activity provider is unchanged from main. Channel and Solo rows keep their bottom padding so they stay at the 44 pt minimum tap height. ### Validation - Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the Home page bell and filter width tests. - Full mobile suite: all pass except 11 push and notification-toggle tests that fail identically on an unchanged checkout in this environment. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Home, the bell, Activity and channel navigation, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
446e9e0762 |
Allow operators to set initial NIP-FI policy on create (#9211)
Trusted operators can choose `initial_requires_nip_fi` on create-only community provisioning. True commits required policy (generation 1), validated issuer bindings and the owner with the new community in one transaction. Omitted, null or false preserves optional creation; a non-null field in legacy ensure mode returns coded 400. Missing effective bindings refuses required insertion without leaving a row. The response reports policy read from the stored row. Same-owner create-only replay now accurately reports `existed` instead of `created`; it stays successful and read-only, including after policy disable or issuer-config changes. Different-owner conflict, legacy ensure, bootstrap and the operator authorization gate retain their behavior. This adds no schema changes or server-wide default flag. Upgrade the whole serving relay fleet before callers request true. An old writer may ignore the field and insert an optional row: response checks detect it but cannot undo it, and later lookup remains read-only. Turn caller targeting off before relay rollback. Mode off stores policy but bypasses enforcement, independently of the upgrade gate. ### Related issue Replaces closed #9172. The separate lifecycle API remains in #9176. ### Validation Draft head: `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Independent source review covers this head. Formatting, diff sanity, Clippy, build, all 14 signed HTTP scenarios and full local `just ci` pass at this head. GitHub reports 59 successful and 38 skipped checks, with none pending or failing in the collected snapshot. At parent `b9d9423a6058387f42c310f5a7e4dbe0c8d8b40f`, all 1,978 package tests and 1,346 PostgreSQL tests passed (PostgreSQL concurrency 2). Four deliberate mutations were detected and restored. Clippy then found seven needless borrows in the new HTTP test; this head removes those borrows only. Earlier validation passed 1,344/1,346 PostgreSQL tests at concurrency 8; the replay assertion was corrected and the timeout disappeared at concurrency 2. Failed-run evidence is retained. At this head, the first mandatory push-hook attempt failed all nine boot-lifecycle tests on child startup deadlines. A subsequent serial run passed all nine (seven PostgreSQL-only cases skipped), with unchanged deadlines and source. The first diagnostic was interrupted during compilation without a result; all evidence is retained. A normal-hook retry was stopped when the author explicitly authorized one push with `--no-verify` for this exact commit. That exception skips local pre-push hooks for publication only; failed hooks and remaining validation still gate readiness. Explicit human runtime confirmation remains a separate acceptance step; agents have not attested it. Generated with Codex Validation update at the same draft head: Clippy, build and all 14 signed HTTP workflow scenarios passed, including atomic required creation, stored-policy replay after disable without a current binding, and refusal without leaving a row. The first live harness run stopped after 12 checks because its SQL helper fetched rows after an UPDATE without RETURNING; that harness query was corrected and the complete workflow passed. Relay source stayed unchanged. The full local `just ci` run failed in `mobile-check`: Dart formatting reported 266 changes among 1,068 files, with unresolved `flutter_lints` configuration warnings. The format check used `--output=none`; the worktree remains clean. Repository validation is therefore not green. That failure is retained as historical evidence; see the successful setup-corrected rerun below. Explicit human runtime confirmation remains separate. Final local validation: after `just mobile-install`, `mobile-check` passed without formatting edits, and full `just ci` completed successfully at `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Tracked files remained clean after installation and after the complete run. This confirms the earlier mobile formatting failure resulted from missing worktree dependencies. All failed runs remain preserved. GitHub's current ready-for-review state is preserved; no agent added `buzz-review-completed` or attested human runtime acceptance. --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@buzz.block.builderlab.xyz> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
e670b672b8 |
perf(loadgen): spread seed writes across channel creators (#9206)
## What `buzz-loadgen seed` signed every channel create, member add, and thread root with the community owner. In private channels without human members, the owner also signed all the history. The relay limits HTTP calls per pubkey (default 300 a minute), so a community with thousands of channels can't be seeded against a relay that keeps its default limits. The seeder doesn't retry a 429, so the seed fails partway, and a torn-down `run_id` can't be reused. Take one community with 20,000 private channels: the owner alone signs more than 40,000 writes, which is over two hours of its quota. Human authors had the same problem on a smaller scale. A human in hundreds of consecutive private channels signed hundreds of history posts within seconds. Now the community owner creates its first 8 channels, and each further block of 8 gets its own creator identity (`Role::Creator`, derived from `run_id`). A creator signs its channels' kind:9007 and kind:9000 events and their thread roots, and `run` looks roots up by the creator. That keeps every creator well under one minute of quota, whatever the community's channel count. History and replies still rotate across humans: a private channel's own human members, or its creator when it has none. Because only humans look roots up, the creator signs nothing but roots in their channels. No amount of history or replies can push a root out of `run`'s newest-100 lookup. Human signers can still bunch up, for example a human in hundreds of consecutive channels or a deep thread. To cover that, a seed write the relay throttles now waits out the `retry in <n>s` window the relay names and tries again, up to 10 times, before it counts as failed. Compatibility: a community with up to 8 channels is seeded as before, apart from the retries. An existing fixture with that shape keeps working with `run`, including its root lookups. ## What got simpler Nothing got simpler. This adds a creator role and a retry loop, which is the smallest change that makes large fixtures seedable against default limits. ## Validation - `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets -D warnings`, and `cargo test -p buzz-test-client --lib loadgen`, run on a Linux workstation at the head commit. - `no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota` seeds a private community of 400 channels against a fake relay. The community has wide channels, a tier of humans in 280 channels each, and runs at 2x. The test requires every creator to sign fewer than 300 writes. With the owner signing everything, it fails: the owner signs all 7,212 writes. - `throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed`: when every write gets two 429s it is retried and the seed passes. When the quota never clears, each write is tried 11 times and the seed fails with the quota error. - `private_channels_add_every_member_before_anything_is_posted` now uses 20 channels. It checks that every create, member add and root is signed by its channel's creator, that the owner keeps channels 0–7, and that every other post is by a human member. - `root_lookups_search_the_channel_creators_posts` checks that `run` searches for roots by the owner in a community's first block and by the block's creator after it, across two communities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a17243d846 |
feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170)
## Goal Give operators a way to relieve database pressure in seconds, without restarting relay pods. Today every concurrency limit is an environment variable, so changing one means a rollout. During an incident, that rollout's restart sets off a reconnect storm of its own. ## What changes **Two runtime-tunable caps.** Each is read from an integer feature flag in a new process-wide evaluation context, and re-read every 5 s: | Flag | Caps | Ceiling | |---|---|---| | `buzz.relay.concurrency.history-reads` | subscription-history reads holding a reader connection (the #9132 gate) | the existing `pool − pool/5` share, which keeps readers free for authorization | | `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames running their database checks | none (unbounded today) | - **Flags can only tighten.** A positive value is clamped to the ceiling. Zero, a negative value, no value, or a provider that never initialized leaves the gate at its ceiling, which is today's behavior. Once LaunchDarkly has delivered values, they stay in force through a later outage, so a cap tightened during an incident survives a provider blip. - **Lowering never cancels admitted work.** A lowered gate admits nothing new until the work in flight falls under the new limit. - **Visibility:** `buzz_concurrency_limit{gate}` reports the applied limit (`0` means no limit), and each change logs a line. **One resizable gate.** `HistoryReadGate` becomes `buzz_db::ConcurrencyGate`, a FIFO semaphore whose limit can move anywhere in `1..=ceiling`: - Lowering it forgets free permits and records any shortfall as debt. - A permit released while there is debt is retired instead of returned. - Raising it repays debt first, then adds permits. All of this, and admission itself, settles against the debt under one lock, so a newer limit always supersedes an older one; there is no background resize task to race. Tokio hands a released permit to a queued waiter before it runs; if that waiter is then cancelled, tokio returns the permit to the semaphore directly, so admission retires any permit it gets while debt is outstanding. The AUTH cap uses the same type. **AUTH shedding.** The permit is taken after signature verification, so only real logins queue for the database checks. The wait is bounded at 2 s. A shed login gets a bare close with no `OK`, exactly like an auth timeout. I chose this from what each client does on main today: - **`OK false`:** the desktop app retries on an unjittered 60 s ladder. Mobile parks the session until the app resumes. The agent harness fails at startup. - **Bare close:** the desktop app, mobile, the agent harness and the CLI all reconnect on their jittered, capped backoff. On the relay, the shed is visible as access line `denied/concurrency_limit` and as `buzz_auth_outcomes_total{outcome="concurrency_limit"}`. **The feature-flag provider is chosen at startup.** `BUZZ_FEATURE_FLAGS_PROVIDER` takes `static` (the default), `environment` or `launchdarkly`, with `BUZZ_LAUNCHDARKLY_SDK_KEY` and an optional `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL`: - **Exactly one provider.** The value is parsed into a single provider, so there is no stacking and no fallback, and the evaluator never changes for the life of the process. The variable sits outside the `BUZZ_FEATURE_FLAG_` namespace, so it can never be read as a flag. - **Startup failures.** Local misconfiguration fails startup: an unknown provider, a missing key, or a malformed proxy URL. LaunchDarkly being unreachable, or rejecting the key, does not. The relay starts on declared defaults, the SDK keeps connecting, and `buzz_feature_flags_ready{provider}` shows whether the provider has initialized. A revoked key must not crash-loop every pod over a tuning knob. - **Every build carries the LaunchDarkly adapter.** It stays inert unless selected, so one published image serves every deployment and each deployment's configuration pins its provider. ## Design-doc amendments (`crates/buzz-feature-flags/README.md`) - **Provider selection.** The compile-time "exactly one provider" contract becomes the startup selection above. The only-one guarantee now holds per process instead of per build. - **Startup.** Failing closed now covers local configuration only. - **Guardrails.** "Flags may choose between equivalent implementations only" gains one exception: a flag may set an operational limit as long as it can only tighten it, and shedding under that limit stays retryable. - **Evaluation context.** `EvaluationContext::for_process()` adds a process context, which LaunchDarkly sees as a single `relay`-kind context keyed `relay`. ## What got simpler - **Removed the compile-time provider contract:** the nested fixture crate (with its own 2.8k-line `Cargo.lock`), its 229-line contract test, and the exact-one `compile_error!` guards it required consumers to copy. One startup parse plus a unit test replaces them. - **One gate type serves both caps.** The history-read gate became that generic type instead of gaining a sibling. - **Net change:** about −2.3k lines. ## Review-Proven Rules applied - **#1:** shedding is retryable (`PoolTimedOut` → CLOSED for history, a bare close for AUTH) and never an empty success. Provider failures are logged and surfaced through a gauge. - **#2:** the gate settles every limit change and permit release under one lock. The table test covers lowering while idle, lowering below the count held, lowering then raising before holders leave, lowering then raising past the count held, and restoring the ceiling. - **#3:** the tests drive the production call sites: `Db::get_channel_window_with_session` under a lowered and raised cap (Postgres), and `handle_auth` under a full AUTH gate. The controller test runs the real refresh loop. - **#4:** values are clamped, waits are bounded, and the refresh runs on a fixed interval with skipped missed ticks. - **#9:** AUTH shedding sends clients down their jittered reconnect backoff rather than an unjittered or terminal path (see above). ## Review rework (`92a975c8`, `3f09cb33`) - **Keys renamed** to `buzz.relay.concurrency.*`: LaunchDarkly keys cannot be renamed later, and the flag project may be shared with other services. - **Shutdown:** LaunchDarkly's final flush has no deadline of its own, so `close()` now runs it on a detached thread and waits at most 2 s, after the audit drain. (The blocking pool would not bound it: the runtime waits for blocking tasks when it drops.) - **Refresh loop:** a panicking refresh is caught, logged and counted (`buzz_concurrency_limit_refresh_panics_total`), and the next tick retries, so the caps cannot freeze silently. - **Runbook:** `docs/operator-concurrency-caps.md`, covering how to tighten a cap and the cases in which a tightened cap returns to its ceiling (mistyped or deleted flag, pod started during a provider outage). - **AUTH test:** asserts the second login passes the gate and returns its permit; the gate permit is renamed so it no longer shares a name with the NIP-FI effect permit. Gated on a Linux workstation at `92a975c8` (`3f09cb33` is rustfmt only; `cargo fmt --check` passes there): clippy `-D warnings`, `buzz-feature-flags` (50), `buzz-relay --lib --bins` (1740), the new modules under parallel `cargo test`, and the Postgres AUTH tests (27). Each new test fails under its mutant: the refresh without `catch_unwind`, `close()` with no deadline (hangs until killed), and the AUTH handler with the gate skipped. ## Testing Re-run in full at `3c289134` after review rework, on a Linux workstation: - `cargo fmt --check`; `cargo clippy -p buzz-feature-flags -p buzz-db -p buzz-relay --all-targets --all-features -D warnings`. - `buzz-feature-flags` with and without `launchdarkly` (50 and 24 tests), `buzz-db --lib` (209), and `buzz-relay --lib --bins` (1738), plus a plain parallel `cargo test` of the new relay modules. - Postgres: the `buzz-db` routed-read and history-gate tests (55), and the `buzz-relay` AUTH and connection tests (38). - `a_cancelled_waiter_cannot_reopen_a_lowered_gate` fails with the debt check in `admit` removed. - The new infra-free relay tests are added to the CI unit lane (`Justfile` and `scripts/run-tests.sh`). - E2E against a local relay at `06c39d64`: cap gauges, sequential logins and history under cap 1, a 20-login burst (19 shed with a bare close, all reconnected), and LaunchDarkly misconfiguration and unreachability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cf2c9ba599 |
Merge remote-tracking branch 'origin/main' into honor/nip-fi-upgrade-pod
* origin/main: fix(relay): route live REQ events by each filter's own channel scope (#8519) fix(relay): deliver multi-node fan-out concurrently and never drop silently (#9195) perf(db): bound since-limited events reads with the month-floor literal (#9197) fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204) fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196) Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
01d9778606 |
fix(relay): route live REQ events by each filter's own channel scope (#8519)
🤖 ## Summary **The problem.** A Buzz client that watches one channel's details (its name, topic, members) gets them once and then never hears about changes. If someone renames the channel, the client keeps showing the old name until it reloads. A related problem: when a client watches a channel's messages and something community-wide in the same request, the channel's new messages stop arriving. **The fix.** The relay (the Buzz server) now decides where to send new events separately for each part of a request, instead of once for the whole request. ### Background: how a client watches for events A client sends the relay a **subscription**: a request made of one or more **filters**. Each filter describes some events, for example "messages in channel X" or "the details of channel X". The relay answers in two phases: 1. **History.** It sends the stored events that match, then a marker that says "history is done". 2. **Live.** It keeps the subscription open and pushes each new matching event as it arrives. For the live phase, the relay must decide which new events a subscription may receive. It sorts new events into **topics**: one topic per channel, plus one community-wide topic for events that belong to no channel. A subscription only hears the topics it signed up for. This is the security boundary that keeps a private channel's events away from people who cannot read it. ### Why it broke The relay picked the topics **once for the whole subscription**, with one rule: if every filter names its channels with an `#h` tag, listen to those channels; otherwise, listen only to the community-wide topic. That rule fails in two cases: - **Channel details.** A channel's details are a special event (kind 39000) that the relay signs and stores under the channel itself. Clients ask for it with a `#d` tag, not `#h`: `{kinds:[39000], "#d":[X]}`. Because the filter has no `#h`, the rule chose the community-wide topic, which never carries channel X's events. The client got the current details in history, and then no updates. (NOSTR.md documented this gap as a future enhancement.) - **Mixed requests.** If a request had one filter for channel X's messages and a second filter with no channel, the rule chose community-wide for both. The channel filter silently lost its live messages. ### What changes Each filter now gets its own topics: | Filter | Live events it receives | |---|---| | Names channels with `#h` | New events in the channels it names and may read | | Channel details (kinds 39000–39003) that name channels with `#d` | Updates for the channels it names and may read, plus community-wide events | | Names no channel | Community-wide events, the same as before | The relay then sends a new event only through a filter that **both** matches the event **and** listens to that event's channel. ### Why this is safe - **No filter can see more than it already could.** A filter's channels come only from the channels it names, and each one must pass the same read-permission check the history phase uses. A private channel's outsider gets neither history nor updates (tested). - **One filter cannot widen another.** This is the reason for per-filter topics. The simple fix, "add the `#d` channels to the whole subscription", would leak. Some channels, such as system channels and future channel types, appear only to a filter that names them. With one shared set of topics, a sibling filter such as "all channel details created by person P" (`#P`) would start to receive live updates for such a channel, even though that channel never appears in its history. #8469 closed this leak for the history phase. This PR keeps the live phase consistent with it (tested). - **Requests that worked before behave the same.** A request whose filters all name `#h` channels got the union of their channels before. Each filter's own `#h` condition already rejected the other filters' channels, so per-filter topics deliver exactly the same events. A filter with no channel keeps the community-wide topic, as before. - **Recent joins are not missed.** Each server caches who may read which channel for a few seconds, so a server can briefly miss a new member. When a request names channels the cache does not list, the relay checks them against the database. Before, it did this only when every filter used `#h`. It now checks every channel any filter names, including `#d`. This matters more now, because a live scope is fixed when the subscription starts: a missed channel would stay missing until the client subscribes again. The check keeps the same token restrictions (tested). More than 2048 `#h` channels is still refused. More than 2048 named channels in total because of `#d` is not refused: the `#d` names then skip this check and live routing, so the request gets history from the cached access set and community-wide live updates, as before this PR. The legacy desktop app sends one unbatched `#d` query for every channel the user is in (tested). - **Clean up stays exact.** A subscription signs up for each topic once, even when several filters name the same channel. It releases each topic once on close, on replacement with the same ID, or when access to a channel is revoked. Revoking one channel removes only that channel; the subscription keeps its other channels and its community-wide topic. The tests check the counts on each path. ### Not in scope A filter that reaches a channel without naming it still gets that channel's events in history but not live. This is unchanged: before this PR, such filters listened only to the community-wide topic too. Examples are a channel-less filter, which history fills with every channel the reader can see, and the "all system channels" opt-in (`#t:["system"]` or `#P`). ### Related issue Follow-up to #8469. ### Testing - Two new Postgres tests drive the real request handler. (1) A channel-details subscription receives updates after history, from this server and through the handler that receives other servers' events from Redis. (The test calls that handler directly. It does not cover publishing or Redis transport.) A private channel's outsider receives nothing. (2) In a mixed request, the channel filter and the channel-less filter each keep their live events, and a sibling filter by creator (`#P`) or system label (`#t`) does not receive a channel it did not name. - A Postgres test with a stale access cache: a just-joined channel still reaches both a `#d` filter and an `#h` filter beside a channel-less one, in history and live. - New unit tests cover the per-filter topic rule and the subscription registry. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
27ab6a085a |
fix(relay): deliver multi-node fan-out concurrently and never drop silently (#9195)
## Problem
Each relay node runs one consumer for events that other nodes publish to
Redis. It delivered them one at a time and awaited the access gate
(visibility and membership lookups) for each event. Under load it fell
more than 4,096 events behind the pub/sub broadcast. Each `Lagged(n)`
then skipped `n` events for good: nothing replayed them, and plain
subscribers were never told.
In a 15-node load test, about 50% of cross-node events were skipped at
10x load and about 75% at 13x. Throughput at 13x was also *lower* than
at 10x. Loadgen didn't show this, because it only times events that
arrive.
## Change
- **Concurrent delivery.** A dispatcher reads the broadcast and only
routes. It drops local echoes and queues each event on one of 16 workers
by `(community, topic)`. Each worker delivers its queue in order, so a
channel's events keep their order while different channels deliver
concurrently. `handlers/multinode_fanout.rs`
- **Known loss closes the affected connections.** An event is lost when
its worker's bounded queue (1,024 events) is full, or when it isn't
delivered within 2 s of this node reading it from Redis, whether it
waited in the broadcast, behind a backlog, or in a stalled delivery.
`ChannelEvent` now carries `received_at`, stamped by the pub/sub
subscriber. For a lost event, the relay sends `CLOSED "resync: live
delivery lagged"` to every sync the event would have reached (NIP-AR
Sync rule 5). It also closes every connection the event would have
reached, so those clients reconnect and catch up from their cursor.
Matching runs before the access gate, so a recipient that would have
been filtered out may be closed too. That costs a reconnect, never a
leak.
- **A lost ephemeral event is only counted.** Presence, typing and
observer frames are never stored, so no reconnect can replay them.
Losing one fences and closes nothing; it is still never delivered past
its deadline. Without this, one stale global presence event would close
nearly every mobile connection in the community, since every mobile
client subscribes to presence.
- **Unknown loss closes every subscribed connection.** If the dispatcher
itself lags the broadcast, it can't know which events it skipped. It
resyncs every sync (unchanged) and closes every connection that has a
subscription.
- **Immediate fence.** Each affected connection is fenced as soon as the
loss is detected. The live fan-out send path (`send_to_text_bytes`,
local and cross-node) delivers it nothing from then on. The fence is
written under the connection map's write guard, and the send path checks
it and queues its frame under one read guard, so no frame slips in
between the check and the send.
- **Why 2 s.** The mobile client resumes from the newest `created_at` it
received, minus 5 s of slack. The desktop app (block/buzz-app) resumes
960 s before its connection last heard the relay, which already covers
the full 900 s `created_at` drift. While an event waits, local fan-out
and other workers keep moving that cursor. The fence stops the cursor,
and the deadline means no withheld event is more than 2 s older than the
fence, so a reconnect replays it. Without the deadline, an event stuck
behind a long backlog could be withheld past the slack and never
replayed.
- **Paced closes.** Only the close is delayed. One closer task closes at
most 50 connections per 10 ms (5,000/s per node). A drop in a hot
channel therefore can't become a reconnect storm (Review-Proven Rule 9).
- `fan_out_pubsub_event` is now split into `take_local_echo` and
`deliver_pubsub_event`, so the dispatcher can check echoes before
queueing. Its behavior is unchanged.
- **Limits (mobile):** replay works while the 2 s deadline, plus the
writer's clock skew and its publish delay, stays under the client's 5 s
slack. The relay accepts `created_at` up to 15 min off, so a writer
clock more than about 3 s slow can still put an event behind the cursor.
An ordinary reconnect has the same limit today; closing it needs a
client rewind instruction, which is out of scope here. The desktop app's
960 s resume window has no such gap. A dispatcher that falls behind
without overflowing the broadcast still never delivers a late event, but
it detects the loss, and fences, late. Events skipped by dispatcher lag
are never seen. Neither case is bounded by the deadline, which is a
cutoff for attempting delivery, not a guaranteed fence time.
- **What a load-test rerun should watch:** under sustained overload the
deadline turns backlog into steady closes (up to 5,000/s per node), and
reconnecting clients replay from the same database pool. Watch
`buzz_multinode_fanout_dropped_total{reason="stale"}`,
`buzz_multinode_fanout_lag_closed_total` and the reconnect REQ rate next
to pool wait. On dispatcher lag, the dispatcher scans the subscription
registry and fences each subscribed connection synchronously, so also
watch how long it stays behind after a `Lagged`.
Metrics:
- `buzz_multinode_fanout_lag_total` (dispatcher lag) and
`buzz_multinode_fanout_total` (delivered) keep their meaning.
- `buzz_multinode_fanout_dropped_total{reason}` counts lost events:
`queue_full`, or `stale` for one not delivered by its deadline. A lost
ephemeral event counts as `ephemeral` instead and closes nothing.
- `buzz_multinode_fanout_lag_closed_total` counts close attempts for a
loss, one batch at a time.
## Rollout
- Staging first. The load-test rerun at 1x, 10x and 13x happens after
merge, with the staging load tests planned once the current relay
changes land. At 1x, expect zero `dropped_total` for stored kinds and
zero `lag_closed_total`. Report delivered %, `dropped_total{reason}`,
`lag_closed_total`, reconnect REQ rate and pool wait.
- Then one production cluster before the rest.
- **Rollback trigger:** `lag_closed_total` climbing steadily, or
reconnect REQs pushing pool wait up, at normal load. Rollback is a plain
revert and redeploy: there are no schema, config or wire changes.
## Ordering
Order is kept within a `(community, topic)`. Events on different topics,
for example a channel event and a global event, can now reach one
subscriber in either order. Before this change they arrived in Redis
arrival order. Nothing in the relay depends on order across topics: the
access gate re-reads membership and visibility per event. Clients order
by `created_at`.
## What got simpler / known debt
- The fan-out loop in `main.rs` shrinks to one `spawn` call, and the lag
handling lives next to the delivery code.
- No new config knobs. The worker count and queue size are constants
until a load test shows they need tuning.
- **Known debt, not in this PR:** a Redis pub/sub reconnect can still
lose events silently for plain subscribers. Syncs are already resynced
in that case (`artifact_sync::watch_link`), but ordinary subscriptions
are not.
## Tests
`handlers::multinode_fanout::tests`, each checked with a mutant on Blox:
- events reach their subscriber through the workers, in publish order
- a full worker queue closes exactly the dropped event's recipients
- a lost ephemeral event (full queue or stale) is counted as `ephemeral`
and neither fences nor closes, while a stored event lost on the same
queue still closes
- a local echo on a full queue closes nothing
- dispatcher lag closes every subscribed connection
- after a known loss or dispatcher lag, a recipient gets no newer live
event while it waits to be closed
- the fence also withholds relay-local fan-out
- an event that waited out its deadline before dispatch is lost, never
delivered
- an event behind a backlog is delivered inside its deadline, and past
it is lost (its recipient fenced and queued for close), never delivered
late (paused clock)
- a delivery that stalls on a database that never answers is cut off at
the deadline and handled as lost
- the closer cancels in paced batches (paused clock), and counts each
batch as it closes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
0d5edb8bb3 |
perf(db): bound since-limited events reads with the month-floor literal (#9197)
## Summary A newest-first `events` read whose own `since` is at or above the recent-pass floor now runs as a single recent pass. Postgres prunes it to the recent monthlies at plan time instead of locking every partition. Results are unchanged. **Why.** `split_for` skipped any read whose `since` was at or above the floor (`since_prunes`), on the theory that its `since` already prunes. It does, but only as a bind parameter. Once a prepared statement switches to a cached generic plan, Postgres prunes bind-parameter bounds at execution, after `AcquireExecutorLocks` has already locked every `events` partition and its indexes. Live REQs with a recent `since` are this shape, for example `#p` mention reads from reconnecting agents. Under load they spill past the 16 fast-path lock slots and contend on `LWLock:LockManager`. The ceiling PR (#9157) named this gap as identified debt. **What changes.** Such a read now gets the same `Split` as other recent reads, so the recent pass carries the floor literal (plus the ceiling literal for regular-kind reads) on `e.created_at` and on `m.event_created_at` for `#p`. `Split` gains `older: bool`. It is false when the read's own `since` is at or above the floor, because no row below the floor can match, so no older pass follows a short recent pass. A read whose `until` falls below the floor stays unsplit, as before. No protocol, schema, index or client change. ### Related issue Follow-up to #9157 (its identified debt: bind-parameter `since` reads). ### Testing - `recent_pass::tests::split_eligibility`: the table now records not split / ceiled / open and whether an older pass follows. New rows: `since` at the floor, a recent `since` with regular, any and addressable-only kinds, and a recent `since` with `until` below the floor. - `recent_pass::tests` in-memory exactness: adds a `since`-at-floor split that runs exactly one pass and returns the rows at or above the floor. - `postgres_tests::since_bounded_mention_read_locks_only_recent_partitions` (new): runs partition maintenance, inserts `#p` rows at the floor, now, one second below the ceiling and one day below the floor, then reads with `since = floor`. The production path returns the three recent rows through the mention join. Under `plan_cache_mode = force_generic_plan` the recent pass locks only the partitions holding those rows. The unsplit read locks every partition. - The partition-lock helper from #9157's test is now shared by both lock tests. - Diagnostic on Postgres 17.11 with the 19-partition layout partition maintenance keeps, for a `#p` read with `since` at the floor (not committed): | | `events` partitions locked | Relation locks | Non-fast-path locks | |---|---|---|---| | Before (bind-only `since`, generic plan after five runs) | 19 | 26 | 10 | | After (floor and ceiling literals) | 2 | 9 | 0 | `EXPLAIN (GENERIC_PLAN)` before appends all 19 partitions. After, it appends only the previous and current monthlies. Mutation-checked on Blox. Each of these fails the new Postgres test or the unit tests: removing the `since_prunes` split, ignoring `older`, and dropping the `e.` literal. ## What got simpler Nothing got simpler. The change adds one flag to the existing `Split` type and no new concept. The duplicated partition-lock reader in the #9157 test became one shared helper. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a25f3cd1d7 |
fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204)
Replaces Agents’ inline sign-in row with Home’s reconnect capsule. It appears at the top of Agents, scrolls with the agent cards, and remains available until leaving the page or signing in. Home is unchanged. Validated with mobile checks, the full test suite, scrolling and sign-in/re-entry regression journeys, and an approved iPhone Air visual preview. The device preview forces the capsule visible while signed in; that override is excluded from this PR. | Before (widget render) | After (widget render) | | --- | --- | |  |  | <details> <summary>iPhone Air and large-text snapshots</summary> Physical iPhone Air, with preview-only visibility override:  3x text, 320×568 widget render:  </details> --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
c8d736642e |
fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)
Follow-ups to the CLI's Buzz desktop credential support, for a person running the CLI with a desktop pairing. - **The "no credentials" error names the relay.** It says which relay the CLI tried and which relays this CLI is paired for, and tells you to pass `--relay`. When a pairing exists for that relay but no running desktop accepts it, it says so instead. - **Default relay for a paired person.** With no `--relay`, no `BUZZ_RELAY_URL`, no inherited agent credential and no explicit key, the CLI uses the one relay that a running desktop still accepts this CLI's pairing for, confirmed by `describe` on a same-user connection. Stored pairings alone never count. With zero or several such relays, the default stays `http://localhost:3000`. A desktop's refusal fails the command with its message. When another paired desktop's check fails (its same-user check times out or can't confirm the owner, or it stays rate limited), the command fails rather than pick a relay, even if one other relay was accepted, since that desktop may accept a relay of its own; a timeout or exhausted throttle fails with a retryable error. `auth desktop unpair` never runs discovery, and `auth desktop pair` never asks other desktops for their pairings, so one desktop's refusal can't block pairing another. Help text updated. - **`buzz auth desktop list`.** Lists each pairing's instance, relays and pubkey, with `accepted`, `not accepted` (revoked or no longer bound), `not running`, `unconfirmed`, `rate limited` or `refused` (the last three with an `error`), using the same check as the default relay. Desktops are asked at once, without waiting out throttling. It never prints tokens, needs no relay, prints `[]` with no pairings, and `--format compact` drops the pubkey. - **Retryable same-user timeout.** When a paired desktop's same-user check times out and nothing else serves, the command now fails with a retryable `unavailable` error (exit 2) instead of "no credentials" (exit 3). - **Regression tests** for the NIP-42 AUTH event naming the relay with its trailing slash, for skipping a paired desktop whose user can't be confirmed, without sending it anything, and for relay selection never asking desktops for pairings with an explicit key, `auth desktop pair` or `auth desktop unpair`, or over an inherited agent relay. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
5b2eb6f093 |
Merge remote-tracking branch 'origin/main' into honor/nip-fi-upgrade-pod
* origin/main: perf(relay): serve restriction rows for 10 s with cross-pod invalidation (#9199) Pin sqlx to the upstream fix for a cancelled BEGIN (#9192) feat(cli): manage sidebar sections with `buzz sidebar sections` (#9191) Show mobile floating dates while scrolling (#9185) fix(relay): regenerate missing image thumbnails on request (#9174) feat(loadgen): model agent turns and sustained holds (#9182) fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168) Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
698132b34c |
Use a named policy command test handler
Verify signed bodies and query-bound reads through the functional command journey. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
b0d006618e |
Expose one set of current policy preconditions
Exercise retained historical receipts and deleted targets through the supported status API; refuse UUID reuse without mutating policy. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
7b495d126f |
Remove unfenced FI policy writers and routes
Keep old receipts readable through policy status without retaining an unfenced mutation path. Require explicit CAS for every new write. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
636986f1ec |
Remove the legacy FI operator command
Consolidate signing, bounded transport and input validation in the policy command; retain their functional tests and selectors. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
744f3946f6 |
perf(relay): serve restriction rows for 10 s with cross-pod invalidation (#9199)
## Summary
The restriction cache (`AppState::restriction_state_cached`) is the
relay's hottest auth read. With a 1 s max age, most per-request
ban/timeout checks miss and hit Postgres. This change serves cached rows
for **10 s** and makes every restriction write invalidate them on every
pod, so the longer age only bounds a pod that misses the notification.
That's the same bound the membership cache already uses.
### What changes
- **`RESTRICTION_CACHE_MAX_AGE` goes from 1 s to 10 s.**
- **Cross-pod invalidation for every restriction writer.**
`AppState::invalidate_restrictions(tenant)` drops the community's rows
locally and publishes a new `CacheInvalidation::Restrictions` over the
existing Redis cache-invalidation channel. The writers that call it:
- kind 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout
(`moderation_commands.rs`)
- admin HTTP `DELETE /members/{pubkey}/ban` and `/timeout`, which
invalidated nothing before
- report-resolution and direct ban/timeout, at the enforcement
convergence point so crash recovery repeats it. Timeout invalidated
nothing before.
- recording an agent's owner (HTTP token/NIP-OA owner link and both
invite claims, through `disconnect_unowned_agent_clusterwide`). The
agent's cached row predates the link and lacks the owner's restriction;
the old disconnect-path drop used to cover this.
Before this change, unban, timeout and untimeout cleared only the
handling pod, and bans cleared other pods only through the disconnect
path.
- **Community-wide drop.** An agent's row also carries its owner's
restriction (`restriction_state` reads `users.agent_owner_pubkey`), and
the cache can't tell which agents a target owns. Dropping the
community's rows covers owner→agent without a lookup. Restriction writes
are rare next to the reads the cache absorbs.
- **Stale-refill fence.** Before, a read in flight when a ban landed
could refill the old "allowed" row. The 1 s age bounded that; at 10 s it
needs a fence. The restriction cache now uses the epoch-fenced fill the
community-active cache already had: a fill is published only if no
invalidation happened since its read began.
### What got simpler
- That shared-read, epoch-fence and max-age logic is now one generic
`FencedCache<K, V>` (`fenced_cache.rs`). `CommunityActiveCache` becomes
a type alias over it.
- The restriction cache drops its hand-rolled fill in `state.rs`, plus
`CachedRestriction` and the separate `restriction_reads` gate.
- `disconnect_pubkey_local` no longer touches the restriction cache, so
roster removal and self-leave stop flushing it.
Net +146 lines, tests included (new writer assertions plus the generic
cache tests, moved from `community_active.rs`).
### Accepted bounds
- A pod that misses the Redis notification (subscriber reconnect window,
or an older pod mid-rollout) serves a stale row for at most 10 s. Older
pods log and skip the unknown `op`. Bans still close sockets clusterwide
through conn-control, and socket admission and moderation-command actors
still read fresh.
- `banned` is computed at read time, so an expiring ban can outlast its
expiry by up to 10 s. That fails closed. Timeouts still lift on time,
because the cached row carries `muted_until`.
- An owner link drops the whole community's rows, not just the agent's,
because the cross-pod message is community-scoped. A link happens once
per agent, so the extra refills are negligible.
### Identified debt (not in this PR)
- `open_channels_cache` and `HostCache` hand-roll similar read_at/epoch
logic. `open_channels_cache` could move onto `FencedCache`.
- Concurrent waiters whose shared read *fails* each re-read on their own
(`SharedReads::turn`). Sharing the failure is a separate change.
## Test plan
- `fenced_cache` unit tests cover:
- concurrent misses sharing one read
- entries served inside the max age and expired past it
- invalidation fencing an in-flight fill and its waiters
- scoped invalidation dropping only matching keys while also fencing
fills
- errors not being cached
- `state::tests::restriction_invalidation_drops_the_community_rows`
checks that local and received drops remove the target's and an owned
agent's rows, and that another community's rows stay.
- `owner_link_drops_the_agents_cached_restriction_row`: a timed-out
owner's fresh agent, admitted over HTTP with that owner, is refused its
first write. Removing the owner-link invalidation fails it.
- Postgres tests now assert that the cached row is dropped by:
- kinds 9040–9043
(`restriction_commands_drop_the_cached_restriction_row`)
- admin HTTP unban and untimeout, with other-community isolation
- direct ban and timeout through the enforcement convergence point
- Mutants (epoch check, scoped epoch bump, `Restrictions` arm, admin
unban invalidate, convergence invalidate, 9040 invalidate) each fail
their test.
- `stored_writes_honor_cached_ban_until_it_ages_out` pins the 10 s bound
with literal ages: a 9 s old cached ban still refuses a write, and a 10
s old one is reread. Raising `RESTRICTION_CACHE_MAX_AGE` to 11 s fails
it.
- On a cloud workstation: fmt, file-size, clippy (`buzz-pubsub`,
`buzz-relay`), the nextest unit suites, and the full `buzz-relay`
Postgres lane. In the full lane, 622/626 passed. The other 4 are tests
this change doesn't touch, and each passes in isolation at both the base
commit and this head.
- Two relays sharing one Postgres and Redis: a timeout, untimeout and
admin-HTTP lift on one relay take effect on the other's next write, a
few ms after the first relay acknowledges.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
abb2586f8c |
Pin sqlx to the upstream fix for a cancelled BEGIN (#9192)
## Problem sqlx 0.9.0 counts a transaction only after Postgres acknowledges its `BEGIN`. If the future is dropped while `BEGIN` is still in flight (a cancelled REQ history read, a client that disconnects, a timeout), sqlx doesn't queue a `ROLLBACK`. The pool's release ping then reads the late `BEGIN` reply, and the writer pool hands the connection to the next borrower while it is still inside that transaction. The reader pool already closes such connections in its release check (`Db::read_pool_options`). The writer pool has no release check. Load tests showed what happens next: - `there is already a transaction in progress` (25001) on the next event read's `BEGIN`. - Sometimes a burst of `current transaction is aborted` (25P02). The leftover from the event read is `READ ONLY`, so the next write fails and aborts it, and every later statement on that connection fails until the connection is closed. During a burst, ban checks failed closed with 503 and window reads returned 500. - A read-write leftover would also leave autocommit writes uncommitted, and the next read's `ROLLBACK` would discard them. ## Change - Pin every sqlx crate to transact-rs/sqlx#4394 (merged, not yet released). That commit takes the transaction count before the `BEGIN` round trip, so a cancelled begin queues its `ROLLBACK`. The crates share types, so they have to move together. The pin also picks up the other 23 commits between v0.9.0 and that fix, including the libsqlite3-sys fix `sqlx-sqlite` was already pinned for. - Add a regression test, `writer_pool_postgres_tests::begin_cancelled_in_flight_leaves_no_writer_transaction_open`. It holds `BEGIN` in flight behind a paused proxy and times it out, for both the event read's `BEGIN ... READ ONLY` and a plain `begin()`. It then asserts that the next borrower's autocommit write commits and that its backend is `idle`, not inside a transaction. Other upstream changes in the pin that reach the Postgres path: `TCP_NODELAY` on sockets (transact-rs/sqlx#4336), reuse of the TLS connector across connection attempts (transact-rs/sqlx#4242), a fix for a memory leak when the statement cache is disabled (transact-rs/sqlx#4337), a describe-time generic-plan change used only by the macros (transact-rs/sqlx#4286), and the `whoami` default-user fix (transact-rs/sqlx#4303). The rest are SQLite, MySQL, CLI, or docs changes. Remove the pin once sqlx 0.9.1 ships. ## What got simpler The lockfile loses its second `sqlx-core` copy, which the sqlx-sqlite-only pin used to pull in. No runtime code changes. A pool-level release check would have fixed the same bug, but it costs a round trip on every writer release. Comments that described the 0.9.0 behavior as current are refreshed: the reader pool's release check (`Db::read_pool_options`) and the lease heartbeat (`EntityDeletionStore::heartbeat`). The reader test `proof_timeout_mid_begin_leaves_no_reader_transaction_open` asserted that the release check closed the connection left mid-`BEGIN`. With the pin, sqlx rolls that transaction back itself, so the test now asserts the check finds nothing to close. That also catches a sqlx regression the check would otherwise hide. Identified debt (not in this PR): the release check's open-transaction arm, and the reader-session `default_transaction_isolation` pin it depends on, are now only a backstop against a sqlx regression. They could be removed in a follow-up. ## Testing - Against sqlx 0.9.0 (main's `Cargo.toml`/`Cargo.lock` at |
||
|
|
e43d57c6ba |
feat(cli): manage sidebar sections with buzz sidebar sections (#9191)
Replaces #8759, which had its head on a fork. This branch also carries
the stars/mutes (#8781) and sort mode (#8784) work that merged into it.
## Summary
Sidebar sections were desktop-only. They live in the encrypted
kind:30078 `channel-sections` preference, and the CLI had no command for
it. An agent that mirrors a team's Slack channels into Buzz could create
the channels but not group them into sections.
This adds `buzz sidebar sections`:
| Command | What it does |
|---|---|
| `list` | `{sections: [{id, name, icon?, order}], assignments:
{channel_id: section_id}}` |
| `create --name <n> [--icon <emoji>]` | Adds a section at the end;
returns `section_id` |
| `update --section <s> [--name] [--icon \| --clear-icon]` | Renames a
section or changes its icon |
| `delete --section <s>` | Deletes a section; its channels go back to
the default list |
| `reorder --section <s>...` | Sets the section order; unlisted sections
keep their order after the listed ones |
| `move --channel <uuid>... --section <s>` | Moves one or more channels
into a section |
| `unassign --channel <uuid>...` | Returns channels to the default list
|
`--section` takes a section id, or a section name when the name is
unique. One `create` and one `move` per section set up a whole sidebar:
```bash
buzz sidebar sections create --name "Engineering" --icon "🛠"
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
```
## Design
- **Wire compatibility.** `sidebar_doc.rs` ports desktop's per-field LWW
register document (`channelSectionsSync.ts`, `sidebarLwwMap.ts`,
`sidebarLaneStore.ts`): decode, shape validation, projection, encode,
and `setRegs` stamping (`v = max(now, newest + 1)`, one shared stamp per
edit, unchanged leaves not rewritten). CLI writes merge with desktop
edits field by field. The CLI also imports the flat (meta-less) format
that mobile writes.
- **One user action is one publish.** Each command reads the head once
and publishes once. A multi-channel `move` therefore lands as one event.
A command that changes nothing publishes nothing and prints `"message":
"unchanged"`.
- **Never overwrite what it cannot read.** If the head fails decryption,
signature verification, or decoding (for example, an unknown `meta.v`),
`list` and every write fail. They do not treat the head as empty. A
write that the relay reports as dominated exits 5 (`Conflict`).
- **Same limits as desktop.** 100 sections, 1,000 assignments, and the
65,535-byte ciphertext cap. Channel IDs are stored in lowercase
hyphenated form, which is the form desktop matches.
- **No relay change.** The relay already stores `channel-sections` as a
generic replaceable kind:30078 event.
## Scope notes
- The commands always edit the **signing identity's own** sidebar,
because the document is NIP-44 encrypted to self. A local agent that
runs with the user's key (for example, `BUZZ_PRIVATE_KEY` set to the
user's nsec) sets up that user's sidebar. A managed agent with its own
key edits only its own sidebar.
- The request also mentioned "an equivalent agent/MCP tool". Agents use
`buzz-cli` for agent-facing operations, so the CLI help and
`crates/buzz-cli/README.md` document the `list` output contract. This PR
adds no separate MCP tool. This PR does not change `desktop/`
(deprecated; see #8840).
- `create` does not dedupe by name, which matches desktop. If a setup
script runs twice, it creates duplicate sections, and later `--section
<name>` lookups then fail as ambiguous. They do not pick one silently.
## Testing
- `cargo test -p buzz-cli`: 513 pass. The sidebar tests are:
- A journey through the production command functions against a fake
relay. The fake relay checks the head filter (`kinds`, `authors`, `#d`,
`limit`). The journey covers list, create, a multi-channel move as one
publish, a no-op repeat, invalid input with no publish, reorder, update,
unassign, delete, and the decrypted event's kind, tags, and document
shape.
- Rows for head selection (newest `created_at`, lower id on a tie,
another author's event ignored), `created_at = head + 1` after a
future-dated head, a mobile flat head, an unreadable head, a tampered
head, the count limit, the ciphertext cap, the NIP-44 plaintext cap, an
empty `--icon`, and a dominated write (exit 5).
- Each guard was mutation-checked: removing it makes at least one test
fail.
- **Desktop interop:** a CLI-written document was fed to desktop's own
`decodeDoc` and `projectSections` (from
`desktop/src/features/sidebar/lib`, run with node). Desktop decoded it
as a meta document, its projection matched the CLI's exactly, and
`canonical(json) === canonical(encodeDoc(tree))`. So desktop treats the
head as settled and does not republish it.
- **Live read:** `buzz sidebar sections list` against a real relay
correctly decrypted and projected a desktop-written sidebar (2 sections,
2 assignments). I ran no live writes.
- `cargo clippy -p buzz-cli --all-targets -D warnings` and `cargo fmt
--check` are clean.
- An agent review ran. It reported no blockers, and its optional
findings were fixed: tests now bind the relay filter and head selection,
a silently dropped head is an error, a no-op `reorder` writes nothing,
the `safe_int` overflow is fixed, channel IDs are canonical, and an
empty `--icon` is rejected.
### Manual test steps
1. `cargo build -p buzz-cli`, then set `BUZZ_PRIVATE_KEY` (your nsec)
and `BUZZ_RELAY_URL`.
2. `buzz sidebar sections list`: you see your current desktop sections.
3. `buzz sidebar sections create --name "CLI test" --icon "🧪"`: within a
few seconds, a "CLI test" section appears at the bottom of the desktop
sidebar.
4. `buzz sidebar sections move --section "CLI test" --channel <uuid>
--channel <uuid>`: both channels move under "CLI test" in desktop.
5. In desktop, rename the section. Then run `buzz sidebar sections
list`, which shows the new name.
6. `buzz sidebar sections delete --section <id>`: the section
disappears, and its channels return to the Channels list.
The author ran these steps and confirmed the result on an earlier head.
**Live re-test on this head** (rebased on `main` at
|
||
|
|
0a4d209046 |
Show mobile floating dates while scrolling (#9185)
Floating dates in conversations and threads now appear during reader scrolling, then fade to zero over 250 ms after one second idle. Automatic positioning stays quiet, and renewed scrolling reveals the date immediately. Reduced-motion behavior is preserved. Validation: mobile analysis, scroll/timing widget tests, and iPhone Air preview. Full mobile tests run through the push gate. Home and Inbox styling has moved to [#9187](https://github.com/block/buzz/pull/9187); this diff only contains scrolling-date behavior and its supporting tests/helper extraction. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
1e930a3f9b |
Rename NIP-FI policy mutation gate
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
a024ea8b69 |
Describe the legacy FI endpoint command accurately
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
d3bf876202 |
Clarify community FI policy operator safeguards
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
1bb2ad443d |
fix(relay): regenerate missing image thumbnails on request (#9174)
## Summary Clients ask for an image's small `.thumb.webp` thumbnail first and fall back to `.thumb.jpg` on a 404. Images uploaded before [#9003](https://github.com/block/buzz/pull/9003) only have the JPEG, so every client pays an extra 404 for them and transparent avatars keep showing on a black background. The same thing happens when a thumbnail write failed at upload time. When a thumbnail variant is missing but its image original exists, the relay now regenerates the thumbnails on that request and serves them with 200. It uses the same generator and storage writes as upload, which are now one shared `store_image_thumbnails`. Later requests are ordinary hits. - GET (full and range) and HEAD both regenerate, so a HEAD-then-GET client sees the same result. - Regeneration runs only after the existing tenant, auth and sidecar checks for that thumbnail. - It is bounded. At most two regenerations run per relay process (`try_acquire`, no waiting), and originals larger than `max_image_bytes` are skipped. In both cases, and for non-images, missing originals or any decode or storage error, the response is the 404 it is today. Errors are logged at warn. ### Related issue Follow-up to [#9003](https://github.com/block/buzz/pull/9003) and [#9105](https://github.com/block/buzz/pull/9105), which made relays write WebP thumbnails and clients prefer them. ### Testing Two router-level tests against Postgres and MinIO upload a PNG through the real route, then delete its thumbnails: - `missing_thumbnail_is_regenerated_from_image_original`: GET and HEAD of `.thumb.webp` and `.thumb.jpg` return 200 with the right content type, and both objects are stored afterward. With regeneration disabled it fails with `GET webp: 404, expected 200`. - `missing_thumbnail_is_not_regenerated_when_unsafe`: a non-image blob, a missing original, an oversize original and a busy relay (no free permit) each return 404 and store nothing. Once the permits are free, the same request returns 200. Removing the size cap or the permit guard fails it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
3fdd8c6ff1 |
feat(loadgen): model agent turns and sustained holds (#9182)
## Why The loadgen agent polled HTTP `/query` every few seconds. A real agent harness (`buzz-acp`) never polls when idle. It works in bursts around each prompt, and those bursts are the load a fleet of active agents puts on the relay. To answer "how well does the relay handle N active users plus their agents over a sustained period", the loadgen needs to model those bursts and hold them for an hour or more. ## What - **`[agent.turn]`**: an idle agent starts a turn on a Poisson schedule and runs one turn at a time. A turn lasts 0.5–1.5 × `mean_secs`. The steps follow `buzz-acp`: - **Start**: a `limit:1` `/query` finds the message to answer (`http_query`), which stands in for the harness's subscription. Then the harness's own reads (`turn_context` op): for a threaded message, its thread context in one `/query` (root by id, newest 13 replies, the agent's own newest reply) plus a `/count` when the window is full. Then kind:39000 metadata, project home (kind:30621, kind:30617) and the author's profile. Then two kind:7 reactions on the message. - **Work**: typing every `typing_secs` and observer frames of `frame_bytes` every `frame_secs` over the socket, plus `reads` tool reads (`/query`, limit 50). - **End**: a threaded reply, under the root that NIP-10 `resolve()` gives. Then reaction removal: query own reactions, then a kind:5 deletion for each. - One HTTP action at a time runs next to the socket. Socket publishes (presence, typing, frames) don't wait for their OK: each OK is matched as it arrives, one missing after 30 s is an error, and at most 128 wait at once. So the socket and HTTP sides stay on schedule while either is slow, as in the harness. A 2xx response that doesn't decode counts as an error. No agent request starts after the stop. - The request mix approximates sustained turns: every turn reads like a first prompt. A harness already holding a thread reads 25 replies (not 13), batches profile lookups and caches project-home lookups for 30 s, so this reads profiles and project homes more often than a busy harness. - `agent.channel_subscriptions`: one live mentions REQ per channel, like the harness. `agent.presence_interval_secs`: presence heartbeats. - `agent.query_interval_secs = 0` turns polling off. - **Sustained holds**: ramp steps may now repeat a level. A 60-minute hold can be six 600 s steps, each one reported and judged, so drift shows up as a trend. - New op `turn_context`. New scenario `perf/scenarios/local-agent-turns.toml`. `perf/LOADGEN.md` documents the model. All new fields are off by default (`turn.per_hour = 0`, presence 0, no channel subscriptions), so existing scenarios run unchanged. ## What got simpler - The agent loop's per-action branches now go through one `sent` result. The observer frame build, the HTTP submit and the `/query` body read are shared helpers. The old inline frame code is gone. - "Hold for an hour, judge every 10 minutes" is now a ramp of repeated levels. No new abort-window concept was needed. - Identified debt, not touched here: abort is still judged only at step end, so a collapsed relay is not detected until the step closes. Repeated short steps reduce that gap. ## Testing - On a Blox workstation at |
||
|
|
b329e05c5b |
fix(cli): list DMs, attach any file, validate kinds, default the relay (#9168)
Fixes five defects in the `buzz` CLI. - **`dms list` returned `[]`.** It queried kind 41001, which the relay no longer publishes. It now reads the viewer's DM group metadata (kind 39000, `t=dm`, `p=viewer`) and drops every DM listed in the viewer's newest relay-signed visibility snapshot (kind 30622), the same hidden set the desktop uses. It keeps paging until it has the requested number of visible DMs or the history runs out, so hidden DMs never crowd out visible ones. Hidden DMs stay hidden; reopening one brings it back. - **Uploads accepted only images and MP4.** The CLI now leaves type acceptance to the relay, as the desktop does: unsniffable files go up as `application/octet-stream`, and the relay's own allow and deny rules apply. Local size caps match the relay's defaults (50 MB images, 500 MB MP4, 100 MB other files). `messages send --file` adds an imeta `filename` (the basename, with control characters and both path separators replaced by spaces) and writes non-media files as a plain link (`[name](<url>)`) instead of ``; inline media still embeds. - **`messages get --kinds` silently ignored bad values.** Any non-numeric or out-of-range (above 65535) kind now fails with a bad-input error, exit 1. - **Upload errors lost their category.** A missing or unreadable file is bad input (exit 1), and `messages send --file` returns the upload's own error instead of wrapping every failure as `Other` (exit 4). The "auth tag may be stale" hint on every 403 is gone: the relay answers 403 for ordinary permission denials, such as a non-moderator reading moderation data, so the hint misled. - **Agents had to pass the relay by hand.** On macOS and Linux, with no `--relay` and no `BUZZ_RELAY_URL`, the CLI uses the relay an agent's inherited `BUZZ_CREDENTIAL_PROVIDER` token acts on, from its `describe` answer. If that lists several relays, it fails with exit 1 and asks for `--relay`. An explicit flag or env var still wins and is still checked against the credential. Paired tokens never choose a relay, `auth desktop unpair` never asks the desktop, and otherwise the default stays `http://localhost:3000`. Help text says so. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: wpfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
9b072f5a0c |
Use a borrowed operator slice in the policy HTTP fixture
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
7e734cae26 |
Keep policy outcomes compact and satisfy workspace Clippy
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
3c586a384e |
Allow later migrations in listed-name upgrade tests
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
829c899eaa |
Cover required-state barriers and effective disable behavior
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
87c9491404 |
Reuse the configured NIP-FI mode label in policy status
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
019ec5510e |
Manage community NIP-FI policy with fenced enable and disable
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
c97912c404 |
Merge remote-tracking branch 'origin/main' into honor/nip-fi-upgrade-pod
* origin/main: Add avatar color backdrop to mobile agent creation (#9184) Cache channel memberships once per user (#9060) Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
962b3fdd88 |
Add avatar color backdrop to mobile agent creation (#9184)
Adds a blurred, saturated backdrop to mobile’s **Choose avatar** screen that crossfades with the selected avatar or emoji color. Enlarged artwork keeps the effect abstract; the upper fade leaves the bottom controls clear. Supports reduced motion and high contrast. Validated with analysis, the full mobile suite (4,518 passed), light/dark widget previews, and an iPhone Air install with the existing account preserved. ### Snapshots Flutter widget captures: | | Before | After | |---|---|---| | Light |  |  | | Dark |  |  | buzz-review-completed Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
32997e2e1b |
Cache channel memberships once per user (#9060)
## Summary
A cold client in 280 channels can issue 280 separate membership reads,
each checking out a database connection. Cache the user's complete
active-membership set per community so those channel checks share one
read.
Keep the existing membership rules and 10-second freshness bound,
measured from connection checkout. Moka now accounts for snapshot size
with a 64 MiB weighted budget per relay. Each snapshot holds at most
16,384 UUIDs. The database pool controls query concurrency; there is no
separate 16-load limiter. Channel discovery keeps its separate filtering
rules. No schema change or migration.
### Request flow
1. Look up the community/user entry in Moka. A fresh complete snapshot
answers channel checks by binary search.
2. A fresh `PointChecks` marker sends each check to the database; it
stores no permission decision.
3. On a miss, create a lightweight, weighted loading entry. Requests for
that user share its lock.
4. Recheck after acquiring the lock. If waiting for that user’s lock
times out, use a point check.
5. Fetch at most 16,385 membership IDs. Above 16,384, retain a
`PointChecks` marker instead of a partial list.
6. Otherwise, sort the complete list into a compact immutable UUID
array.
7. Replace the loading entry through Moka. Its weigher now includes the
completed payload plus estimated bookkeeping.
8. Let Moka manage admission, eviction, and 10-second expiry. Membership
changes remove the relevant user or community entries.
Publication and invalidation share a short synchronous lock. A loader
publishes only while its original loading lock still identifies the
current entry. An old load can finish its original request, but cannot
repopulate an invalidated entry. Database failures propagate;
cancellation releases the user lock. Both database-query futures
allocate only when used, which keeps nested handler futures small. Cache
hits allocate neither query.
The budget covers estimated retained cache weight. Moka enforces it on a
best-effort basis; query buffers and snapshots held by active requests
can add memory. This is not a strict process-memory ceiling. Empty
entries and fallback markers have nonzero weight. There is no separate
memory-reservation or eviction implementation.
### Operational notes
- A newly granted membership can be absent from another relay's cached
snapshot until the Redis invalidation arrives, or until the 10-second
freshness bound expires if it is lost. Cached write checks can briefly
deny access; explicit channel subscriptions already confirm missing
membership against the database.
- `buzz_membership_cache_hits_total` counts channel checks served from a
fresh snapshot. `buzz_membership_cache_misses_total` counts bounded
full-set load attempts. Timeouts now use point checks, recorded by
`buzz_membership_cache_fallbacks_total{reason="wait_timeout"}`. The
other fallback reason is `oversize`; the `load_limit` path has been
removed. Before/after hit ratios are not directly comparable.
- Keep `channel_id` in membership invalidation messages for
compatibility with older relay instances during rolling deployments,
even though the new local cache invalidates by user.
### Related issue
Follow-up to #9012.
### Testing
**Current head: `a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, including
main `f82944e3180e28502f5ea599ac9973aa6398ddb3`.** Commit `393b32583`
fixes the large-membership test fixture by refreshing planner statistics
after bulk seeding. Before that fix, the boundary test failed locally
and was the sole PostgreSQL test failure in GitHub CI (1,409/1,410
passed); the repaired test passes in 3.54 seconds including setup and
all checks. An isolated copy of the production schema/query at 16,384
memberships selected a quadratic join without statistics and a hash join
after `ANALYZE`; the latter executed in 10.86 ms. The cap and freshness
rules are unchanged.
At this exact head, the full regular buzz-db/buzz-relay suite passed
1,963/1,963 and the full PostgreSQL suite passed 1,310/1,310. This
includes both previously failing Git-auth cases and the repaired
membership boundary test (2.302 seconds including setup and checks).
Rebuilt signed live checks also passed. Full local `just ci` passed
(exit 0). GitHub CI is green: 79 successful checks and 31 skipped, with
none pending or failed at verification. The [verified matched A/B at
this
head](https://github.com/block/buzz/pull/9060#issuecomment-6673813172)
against main `f82944e31` is complete: four 6,600-client arms in both
orders show 90.98–91.24% fewer membership-cache queries and 73.76–74.23%
fewer authorization checkouts. Auth error rates improved in both orders;
auth p99 and overall operation errors were mixed. Average relay RSS
increased 1.6–4.3%, with flat to slightly lower sampled peaks;
PostgreSQL average PSS decreased 1.7–5.2%, with mixed peaks. The
16,384-row production query executed in 24.953 ms after fixture ANALYZE.
Signed pool-4 and 4,097/16,384/16,385-membership probes passed. See the
report for raw counts, memory, provenance and limitations. The
[completed A/B at the earlier `6f0358875`
head](https://github.com/block/buzz/pull/9060#issuecomment-6672419096)
is reported separately; it does not establish current-head performance.
Users above 16,384 memberships still use uncached authoritative point
checks. The higher cap reduces how many users hit that fallback; it does
not eliminate the cost. Each maximum-size list contains 256 KiB of UUIDs
plus overhead within the unchanged 64 MiB estimated cache budget.
Temporary allocations and retained readers can add memory outside that
budget.
Agent-run signed WebSocket test at
`a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, using the rebuilt relay and
an isolated database: non-members cannot post; grants allow both private
channels; removal blocks writes and delivery from A while B continues;
re-add and resubscription restore A. Delivery checks completed in 1.776
s after removal and 0.466 s after re-add. A user grown above 5,000
memberships used snapshots with no oversize fallback. After growing
beyond 16,384, authoritative point checks still enforced removal and
re-add; four oversize fallbacks were recorded. The relay stopped and all
three test ports closed. Binary SHA-256:
`5b69a5aed07e9beef6bd6d2c5661c07fbdf1901dedb1729d27ab5e86a6b8d6a3`.
Build this head with `cargo build -p buzz-relay --bin buzz-relay` and
start an isolated local relay as described in
[TESTING.md](
|
||
|
|
9c962c35ca |
feat(admin): invoke community FI upgrades
Add a one-shot signed operator command and a pod runbook for testing a connected client across a policy change. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com> |
||
|
|
9a6cb3ffdf |
mobile: make the first-agent card stand out in dark mode (#9171)
Follow-up to #9036. In dark mode the "Create your first agent" card used the raised dark surface (#1A1A1A), which barely separates from the black Home page, and dark mode shows no shadow — feedback was that it's hard to see. The card now uses the inverse surface in both themes (light: dark card on white; dark: light gray card with black text), so it always contrasts the page. No new tokens. Tests: `flutter analyze` on the widget; `flutter test test/shared/widgets/buzz_callout_test.dart test/features/channels/` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) | | Before | After | |---|---|---| | Dark |  |  | | Light (unchanged) |  |  | _Rendered from the widget at phone width with the real fonts and artwork._ Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4e28176f13 |
fix(relay): close legacy sockets after community FI upgrades (#8991)
## Summary
When a community starts requiring NIP-FI, the periodic community
revalidator now closes its existing legacy root and audio WebSockets
with Close 1008. It reads current policy from the writer by immutable
community ID, with a 500 ms budget per read. Failed reads retry on later
sweeps; late registrations are caught by the next pass.
Enforced FI admissions survive. Optional-shadow admissions remain
legacy. Off and repair mode retain their existing behavior. No client
request or Redis notification is needed.
The default sweep interval is 30 seconds, not a deadline during database
failure, overload or paused processes. Already-running requests and
descendant work may finish. Receipts prove policy acceptance and remain
`pending`. The `pending_reasons` value changes from
`registry_baseline_unverified` to `connection_closure_unverified`;
clients must tolerate unknown reasons. This change does not certify
fleet-wide completion.
### Related issue
[BUZZ-512](https://linear.app/squareup/issue/BUZZ-512), under the
revised eventual-closure scope. Builds on merged #8985 and #8988. The
replacement starts from main and adds no activation schema or migration.
### Rollout and limits
There is no new flag. Count already-required communities before
deployment: each relay closes their legacy sockets on its first
successful sweep. Closures within a community can cause simultaneous
reconnects. Keep new upgrades disabled until every serving relay runs
this version.
Observe `buzz_community_auth_policy_checks_total{outcome}` and
`buzz_community_auth_policy_disconnects_total` per relay, and verify
real idle root/audio Close 1008 probes plus FI reconnects. Counters
record checks and cancellation requests, not fleet completion. A binary
rollback requires no migration and restores the old pending reason, but
loses eventual closure; it does not undo durable policy. See the
[operator
contract](
|
||
|
|
b894fb02ff |
fix(mobile): show transparent avatars without a black background (#9105)
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a1efbf63bd |
fix(mobile): delete composer chips as one unit (#9133)
In the mobile composer, Buzz links (channel, message, repo, PR, issue) and selected agent `@mentions` render as chips, with the source text kept as invisible characters behind the chip. Backspace right after a chip removed one of those hidden characters, so the chip turned into a broken raw URL or partial name with no visible change for most of the presses. This PR gives chips one consistent editing model: - **A link is a chip only once something follows it.** Its pattern runs to whitespace or the end of the text, so in practice that is any time the caret isn't at its end. Pasting exactly one link adds a space after it (unless whitespace already follows), so it becomes a chip straight away. - **A link shows its raw URL while the caret is at its end.** Backspacing the space after a chip, or moving the caret back past it, reveals the URL for editing, one character at a time. Moving the caret away turns it back into a chip. A caret at the link's start leaves it a chip. - **Agent `@mention` chips stay atomic.** Backspace right after one removes the whole mention. - **Chips are atomic for selection and forward delete.** A caret placed inside a chip moves to its nearer edge, arrow keys step over it, and a selection that covers part of a chip grows to cover all of it. Forward delete just before a chip removes the whole chip. Typing, pasting, deleting or cutting over a selection replaces exactly the (snapped) selection. The controller records each chip's source range while it builds the spans, so only what is actually drawn as a chip behaves as a unit. Text in code spans and IME compositions are never rewritten. Undo restores the previous text: edits at the end of a revealed link are plain text edits, so undo no longer looks like a backspace into a chip. Human `@mentions` aren't chips today and are unchanged. After this change, in the simulator: a link becomes a chip once a space follows it, backspace reveals the raw URL, the next backspace removes one character, and the arrow keys reveal the link and turn it back into a chip. https://github.com/user-attachments/assets/1d051dd7-92fa-47c9-b569-a7ef5e00ca6f <img src="https://github.com/user-attachments/assets/9980aa01-bcf2-4a1b-9c25-4349ce999e26" width="300" alt="Composer with a channel link chip after a space"> <img src="https://github.com/user-attachments/assets/5dfbaa44-a0f4-4917-b7ca-eeeb63e47dae" width="300" alt="After one backspace the raw URL shows"> <img src="https://github.com/user-attachments/assets/1a675d56-7310-4b13-9dd9-caa26ff99283" width="300" alt="The next backspace removes one character"> ### Validation - Added tests: the composer permalink-chip test now covers the boundary space and reveal (soft and hardware backspace, arrow keys), caret and selection snapping, forward delete, paste adding a space, IME composition, and undo after typing at the end of a link or after completing a link. The agent-chip test covers whole-mention backspace, selection snapping over a mention and a mention in a code span. Breaking each rule (reveal, snapping, paste space, whole-chip delete, widening only inside one chip) fails a test. - Focused existing validation: the full mobile suite (`just mobile-check mobile-test`) passes. - Automated app run: iPhone 17 Pro simulator, iOS 27, debug build of this branch, in a private test channel. Typed a Buzz channel link and a space (chip), backspaced twice (raw URL, then one character removed), retyped the character and space (chip again), then moved the caret left past the space (raw URL) and right again (chip). Nothing was sent. The run covered the composer only; the change touches nothing outside it. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbfbf3d64f |
test(mobile): drop the video preview step removed with native frame loading (#9164)
Fixes red main. https://github.com/block/buzz/pull/9144 added a step to the expired-assertion journey in `native_media_headers_test` that loads a posterless video preview through `videoPreviewFrameLoaderProvider`. https://github.com/block/buzz/pull/9138 merged around the same time and replaced native first-frame loading with a static placeholder, removing that provider, so `flutter analyze` now fails on main with `Undefined name 'videoPreviewFrameLoaderProvider'`. Posterless previews no longer fetch anything natively, so the step has nothing left to guard. This deletes it along with its fake video player, restoring the test file to its state before #9144. ### Validation - `just mobile-check` and `just mobile-test` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
1bb44ae9ef | fix(relay): close a WS search whose hit hydration fails instead of sending EOSE (#9148) | ||
|
|
8ae35c5c92 | perf(db): stop the events recent pass at a ceiling for regular-kind reads (#9157) | ||
|
|
bb3c5f7175 |
feat(relay): project compact viewer memberships (BUZZ-615) (#9118)
## Summary Cold login downloads other people's full rosters just to find the viewer's channels. This adds a compact projection from canonical memberships through the existing Nostr `/query` bridge. One primary SQL statement reads the viewer's rows and channel state, then the relay signs that exact result. Merged spec PR #9117 owns both relay-only kind registrations. Self mode (`membership_snapshot`, kind 39008) requires the viewer's own NIP-98 key. Public mode (`membership_public`, kind 39009) lets an admitted requester look up any subject's listed, open forum/stream/workflow memberships. It excludes private/unlisted channels, DMs, system channels and deleted or removed memberships, including shared private channels. Both modes require payload-bound NIP-98 and return signed request-bound envelopes; overflow or backend errors fail the whole request. Legacy kind-39002 reads and signatures stay intact. No schema migration or persisted per-user list is added. ### Related issue [BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). Implements the compact discovery contract merged in #9117. Related: [#8898](https://github.com/block/buzz/pull/8898), [#9060](https://github.com/block/buzz/pull/9060). Client lazy-loading follows separately; this does not include [buzz-app#2627](https://github.com/block/buzz-app/pull/2627). ### Testing Current head `5c0c35400a` rebases the five implementation patches unchanged onto main `c39ee507`, then repairs the removed Redis test API. McKeon independently verified the rebase and reviewed that repair. Full core/database/relay package tests, PostgreSQL membership tests (9/9), Clippy and every mandatory publication hook pass at this head. Old-head CI run 42132655155 failed six jobs on that same removed API; four summary checks propagated those failures. New-head hosted CI is pending. Main advanced to `e3a0f3d59d` during publication; a local merge-tree check found no conflicts. Historical runtime evidence below remains attributed to its tested heads. Prior self-mode validation at `6d2ffceeee` (historical evidence, not validation of the public-mode change): - `just ci`, pre-push hooks and all 2,221 core/DB/relay package tests passed. - All six new Postgres protocol tests passed. All five guard mutations failed assertions: mandatory auth, flag gate, single-statement cut, overflow probe and tenant predicate. A/B are older runs against production files unchanged from `9784df51`; C/D/E were rerun on final SQL/fixtures. The earlier surviving tenant mutation is superseded by the final duplicate-ID witness. - The full isolated Postgres run finished 1,288/1,294 green; six existing push/media/FI/startup cases failed. All six passed when rerun serially after the concurrent local gates stopped. The failed full run is retained and is not represented as a clean full-suite pass. The preceding correction `1c47dfc9` had passed all 1,294 tests. - `just test` passed its unit steps but integration setup was blocked by Docker Desktop organization sign-in enforcement. The waiting wrapper was stopped with SIGTERM; this is not a complete pass. - A real local relay rebuilt at this head returned 67,896 bytes for 468 memberships in 33 ms unthrottled. Signatures, request/viewer/tenant binding, content identity, wrong-viewer refusal, removal and role changes verified. The scale fixture adds five large rosters and 20,000 unrelated channels. Compact bytes stay unchanged as other members grow; EXPLAIN checks 468 indexed viewer rows and bounded channel-ID lookups. A same-UUID cross-tenant fixture verifies fields and duplicate rejection independently of row order. Durable local evidence: `WORK_LOGS/BUZZ_615_20261105/MANIFEST.md` and `MANIFEST.json` map commands/results to commits or verified mutant-file hashes and index the exported logs. Concrete human-test steps and the signed-request helper are included. Human testing remains pending; ready state follows the author’s explicit request. Native/browser 140/205 KB/s acceptance belongs to the client PRs. Public-mode validation at `c6e269170e`: McKeon independently ran all nine isolated PostgreSQL membership tests and five mutations. Every mutation failed its expected assertion: public selection predicate, subject tag, public routing, identity domain and self serialization order. The full core/DB/relay package run, all nine isolated PostgreSQL membership tests and the relay build passed at c6. A real signed HTTP workflow verified signatures, bindings, identity hashes, all visibility exclusions, archived guest roles, requester-independent content identity and malformed-request refusals. After a channel became private, the public list shrank from three entries to two; a role change appeared as admin. The local test relay was stopped afterward. The restacked child `8c56f97d6409` has the same code, tests and configuration as c6; only the spec differs. A new full core/DB/relay package run passed at that exact child head. The parent `17a481a89716` separately passed the full core package check. The first child attempt used the wrong local database and failed five existing tests on a missing table; its log is retained separately from the successful configured run. These checks do not attest human testing. ### Rollout and rollback This PR does not deploy or enable the feature. Both `BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED` default to false and independently gate advertisement and execution. An authorized operator sets them in the relay Helm environment managed by `squareup/block-coder-tf-stacks`. First deploy the image to every staging pod with both flags off; then enable each capability separately for staging validation. Enabling either mode does not require or enable the other. Repeat that dark-then-enable order for bb-block after staging passes. Opt-in client rollout follows the relay contract and the throttled desktop/browser workflow. Watch `buzz_membership_snapshot_query_seconds`, `buzz_membership_snapshot_encode_sign_seconds`, `buzz_membership_snapshot_response_bytes`, split by `mode=membership_snapshot` or `mode=membership_public`, plus `/query` 413/503 responses and primary-pool wait time for the Authorization operation. Confirm zero unexpected full-roster discovery downloads on supported clients and correct roles/membership against the canonical cut. Abort enablement for any binding/signature mismatch, access leakage, incorrect complete result, stale-response regrant, unexpected overflow for the 468-channel account, sustained 503s or a material regression in primary-pool wait/foreground reads. Turn the affected flag off to roll back. Self-mode clients recheck support and start a new legacy authority generation; public-mode clients recheck support and back off without treating public absence as membership loss. Keep cached display/user data. The first named usable channel target of five seconds at 140 KB/s remains a client acceptance target, not a result established by this relay PR. The 119-event approximately 5 MB read-state contention and native/browser throttled tests are required before broad client rollout. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
fd0ec10505 |
perf(mobile): rank search channels once per query (#9136)
Search re-ranked every channel on each live channel event, even while it
sat covered in the back stack, and could move a result while you were
reaching for it. On an iPhone 12 mini in a large community, each re-rank
cost 4 to 20 ms at about 2.4 channel events per second: 454 ms of 2,575
ms UI CPU in a 25 s scroll of a big channel with search behind it, and
343 ms in the 15 s after a resume.
Channel results are now ranked when the query runs and when the set of
channels changes: the first load and a directory install (including one
another caller started). Live channel events no longer reorder shown
results; they only update the shown rows' content (membership badge,
member count, name) in place. The next query ranks the current list.
### Accepted gap
A channel joined, left or renamed while results are shown updates its
row but keeps its position until the next query.
### Validation
- Added test: `live channel events never reorder shown results` adds a
channel and checks it is ranked in, moves it to the top of the list
through activity and checks the shown rows don't move, lets a live event
land between another caller's directory status turning loaded and its
install and checks the open channel shows, then joins that open channel
and checks its Open badge goes without the row moving. It fails when
activity re-ranks again, when a change to the channel set is not ranked,
and when shown rows keep stale content.
- Search suite and full mobile suite pass except the known
notification/push failures that also fail on main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", channel and people results listed, opened
a channel result, went back with the results still shown.
- Device numbers are from a wired Profile build of main
|
||
|
|
1a30e51689 |
mobile: start new agents on the server-recommended model (#9151)
## Summary Builderlab's model catalog can now recommend a model and thinking effort for new agents (`recommended_selection` on `/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now starts on that recommendation, so the default can change on the server without an app release. New agents start on the first of these that applies: 1. The model an agent link suggests, when the catalog offers it (unchanged). 2. The server's recommended model and effort. 3. The first Sonnet model at medium effort, as added in #9034. Used while a deployment sends no recommendation. 4. Blank. A setup resumed after a failure still keeps its saved choice. A recommendation that isn't in the returned catalog, or that is malformed, is ignored. Existing agents' saved models are never changed. The server only sends a recommendation when the deployment's catalog config sets `recommended_selection`. Until then, the app keeps falling back to Sonnet. ## Testing - Unit tests cover the recommendation beating the Sonnet fallback, a draft model beating the recommendation, and recommendations that should be ignored (unsupported effort, unknown model, missing effort, malformed, absent). - A new widget test checks that the form opens on the recommended model and effort instead of Sonnet. - `just mobile-check` and the full mobile suite (`flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5d094cd769 |
release(mobile): push candidate tags as the operator, not the App (#9084)
Mobile release candidates (`mobile-vX.Y.Z-rc.N`) are currently created
by the `mobile-release-candidate.yml` workflow, using the
`buzz-release-bot` App key that also serves the desktop, relay and chart
lanes. This PR takes mobile off that key. After it lands, the only way
to create a mobile candidate tag is for a person on the mobile release
team to push it.
### What changes
- `scripts/mobile-release.sh candidate` keeps all its existing checks:
version, source branch, clean tree, canonical origin, ancestry and next
rc number. It no longer dispatches a workflow. It now:
- verifies the mobile tag ruleset 26588677 (active, the expected rules,
scoped to `refs/tags/mobile-*` or `refs/tags/mobile-v*`, and bypassable
by the current user), and that the App-only general tag ruleset 14378754
excludes `refs/tags/mobile-*`;
- requires origin's effective push URLs to be exactly its fetch URL, and
fetches a pinned `--commit` missing from a shallow clone;
- creates the annotated tag locally and pushes exactly that ref
(`--no-follow-tags`, explicit refspec);
- confirms through the GitHub API that `block/buzz` holds that exact tag
object, then verifies the remote tag as before.
A rejected push removes the local tag only when origin lacks it, so a
retry starts clean. When the push reports failure but origin has the
tag, the script keeps the local tag and still runs the canonical GitHub
check, reporting success only if it passes. When GitHub can't confirm
the tag, the local tag is kept and the message says how to reconcile it.
- `scripts/release-rulesets.sh`: `require_mobile_release_tag_ruleset`
replaces the App-oriented check. `MOBILE_RELEASE_TAG_RULESET_ID` is
`26588677`.
- Removed `.github/workflows/mobile-release-candidate.yml`,
`scripts/publish-mobile-release-candidate.sh` and its test, and their CI
references. Updated `RELEASING.md` and the auto-tag workflow header
comment.
- The desktop, relay and chart auto-tagging is unchanged.
### Rollout
Tag ruleset 26588677 ("Restrict Mobile Tags") is active with the two
mobile release managers as bypass actors. The App (Integration 4349119)
is temporarily a bypass actor of 26588677 and 19321162 so the current
`main` workflow keeps working; it is removed from both right before this
merges. General tag ruleset 14378754 now excludes `refs/tags/mobile-*`;
without that, rulesets stack and it would reject release managers'
mobile tag pushes. The branch ruleset 19321162 now has the same bypass
actors, so `require_mobile_release_branch_ruleset` no longer requires
"no bypass"; it still checks the review and status check policy. Release
managers must be bypass actors of both rulesets.
### Before merge
- [x] Remove Integration 4349119 (`buzz-release-bot`) from the bypass
actors of rulesets 26588677 and 19321162.
### Validation
- `shellcheck -x scripts/release-rulesets.sh scripts/mobile-release.sh
scripts/mobile-release-source.sh
scripts/test-mobile-release-contract.sh`: clean.
- `scripts/test-mobile-release-contract.sh`: passes. It runs against a
local bare remote with a stubbed `gh` and covers:
- a successful annotated push at the pinned SHA;
- ruleset failures (not bypassable, inactive, wrong rules, wrong scope,
excludes, non-numeric ID), with `refs/tags/mobile-v*` also accepted as
the include, each failing before any tag is created;
- a push rejected by a pre-receive hook, which leaves no local tag and
allows a clean retry;
- `push.followTags=true` with a stray local annotated mobile tag
(exactly one ref published), a push that lands but exits non-zero (tag
kept), and a GitHub API outage after the push (tag kept, rerun publishes
the next rc);
- origin push URLs pointing at a fork (pushurl, a second pushurl,
pushInsteadOf, insteadOf), a shallow clone pinning a historical commit,
and 14378754 lacking the mobile exclusion;
- rc numbering, source branch rules and `--commit` pinning.
- `scripts/test-release-ref-contract.sh` and
`scripts/test-desktop-release-candidate.sh`: pass.
- Live read-only check against real GitHub as a bypass actor: sourced
`scripts/release-rulesets.sh` and `scripts/mobile-release-source.sh`,
then ran `require_mobile_release_tag_ruleset` (including the 14378754
exclusion check) and `require_mobile_release_branch_ruleset
mobile-v0.27`. Both returned success. No tag was created.
- No mobile app test: this is a script and workflow change only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
mobile-v0.28.0-rc.4
|
||
|
|
fc85b0d154 |
test(mobile): check posterless video previews renew the relay assertion (#9144)
Follow-up to https://github.com/block/buzz/pull/9134, which made posterless video previews and voice notes renew an expired relay assertion before handing headers to the native player. The voice-note path got a test there, but nothing failed if the video preview went back to the cached-only headers. This extends the expired-assertion journey in `native_media_headers_test` with one step: the real `videoPreviewFrameLoaderProvider` loads a relay video through a fake native video player, which records the headers it is created with. The player must receive both the media `Authorization` proof and the renewed `Nostr-Federated-Identity`. ### Validation - Added tests: the video-preview step above. Switching the loader back to the synchronous cached headers fails it. - `flutter analyze` is clean and the full mobile suite passes (4508 tests). - Test-only change, so no app run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
fba8a7a94f |
feat(relay): log WebSocket EVENT writes on the access target (#9154)
Logs every WebSocket `EVENT` on the access target (`buzz_relay::access`). Before this, accepted writes logged only `Event ingested` on the handler target, and refused or ephemeral writes logged nothing. ### What's logged Each `EVENT` frame writes one `event=write` line when its handler finishes. The line has `conn_id`, `community`, `pubkey` (the authenticated login, if any), `kind`, `event_id`, and `outcome`/`reason`, taken from the OK the relay sent: - `accepted` - `rejected` (INFO), with `reason` set to the OK's NIP-01 prefix (`invalid`, `restricted`, `auth-required`, `duplicate`, `rate-limited`, …). Any other message is logged as `other`. - `error` (WARN), when the OK starts with `error` - `aborted`, when the handler ended before deciding an OK `outcome` is always the relay's decision. If the OK couldn't be queued because the outbound channel was full or closed, the line adds `ok_queued=false`; the field is left off otherwise. `EVENT`s refused before the handler runs are logged too, where their OK is sent: admission rate limits, with admission's reason, and a full handler pool, as `rate-limited`. The OK text after the prefix, the event's content and its tag values are never logged. Lines join to `auth`/`close` lines (and so to `peer_addr`) through `conn_id`, the same way `req` lines do. Rejected writes are capped per connection by the same refusal-budget mechanism as refused reads, on their own counter: the first 16 rejections are logged and the rest are only counted, in a new `writes_suppressed` field on the `close` line. Accepted writes are never capped. ### How `access::WriteLog` sends the OK and records it in one call (`write.ok(...)`). That call replaces every `conn.send(RelayMessage::ok(...))` in `handle_event` and the observer-frame handler, so the line always shows what the client was told. ### Tests - `access::tests`: the reason comes from the OK prefix only; each outcome logs at the right level and without content; rejected lines are capped by the budget and accepted ones are not (draining the channel so every OK is queued); an OK refused by a full or a closed outbound channel keeps its outcome and adds `ok_queued=false`. - `handlers::event::tests::fanout_access`: an unauthenticated `EVENT` going through `handle_event` writes one `rejected`/`auth-required` line, with no content or tag values. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |