mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
a2d4ba02677f157a95ae0d84ef3be37953a7ed24
3460 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a2d4ba0267 |
feat(mobile): use HTTP/2 on iOS through app-wide URLSessions (#8897)
On iOS, Buzz's HTTP traffic now uses HTTP/2. Dart's `dart:io` client speaks only HTTP/1.1, so every feature's client opened its own connections to the relay, which sits behind Cloudflare and offers HTTP/2. On a slow link those extra handshakes, and the HTTP/1.1 head-of-line queueing, are a large share of cold-start time. ### What changes - iOS HTTP goes through two app-wide `NSURLSession`s via [cupertino_http](https://pub.dev/packages/cupertino_http) (`lib/shared/http/app_http_client.dart`): one for API traffic (relay queries, NIP-11, account, admission, pairing) and one for media (image/video/voice downloads and uploads). Requests to a host multiplex over one connection per session. Media has its own session so image bytes can't starve queries on a shared HTTP/2 connection: in prototype measurements with a single shared session, the channel-list batch query finished *later* than on HTTP/1.1. - Callers keep owning and closing their clients; on iOS `close()` leaves the shared session open. Both sessions are ephemeral with no URL cache and no cookies, so apart from the protocol (and native gzip, inflated off the Dart isolate) behavior matches `dart:io`. - Android, and tests, keep `http.Client()` unchanged, including `http.runWithClient` overrides. - Because closing a client no longer cancels a request on a shared session, the relay query client (both `/query` posts and bounded snapshot reads) and the dev push-lease fetch abort their request on timeout instead. - Non-`https` requests (`http://` media and community icons from other clients, local dev relays) stay on the handle's own `dart:io` client, because App Transport Security would block cleartext loads through URLSession. An HTTPS URL that redirects to `http://` is the one case that newly fails on iOS (URLSession follows the redirect and ATS blocks the downgrade); we accept that rather than follow redirects in Dart. - URLSession failures arrive as `NSErrorClientException`, not `SocketException`, so pairing and invite-join error messages now also recognize `NSURLErrorDomain` offline, TLS and timeout codes. - cupertino_http 3.0.1+ requires native_toolchain_c 0.19 and hooks 2.x, so `buzz_secp256k1` moves to those. Its build hook compiles unchanged. ### What gets better, and what doesn't get worse - **Better:** on a slow link, requests reuse one warm connection per session instead of paying TCP+TLS setup each time, so they complete where `main` times out (see the table). On Wi-Fi, small queries are slightly faster. - **Not worse:** request semantics, caching (none) and cookies (none) match `dart:io`; Android and tests are untouched; timed-out requests are aborted so they can't keep using bandwidth on the shared connection; offline errors keep their friendly messages. The accepted regression is HTTPS-to-HTTP redirects on iOS, above. WebSockets stay on `dart:io`. Android (Cronet) is a possible follow-up using the same seam. ### Validation Added tests: shared-session handles don't close the session; off iOS the transport honors `http.runWithClient`; NSURLError code classification; timed-out relay queries and bounded snapshot reads abort their request; cleartext requests stay off the shared session. Focused existing validation: full mobile suite (the only failures are 11 push/notification tests that fail identically on `main` on the same machine), `flutter analyze`, `buzz_secp256k1` package tests (27/27), and an iOS release build. On-device A/B on an iPhone 12 mini against the internal prod relay over WARP, comparing cold starts of `main` and this branch (profile builds with local-only request timing, 3 cold starts each): | | `main` | this branch | |---|---|---| | Wi-Fi: first query done (median) | 0.31s | 0.25s | | Wi-Fi: small-query latency (median) | 0.22s | 0.18s | | NLC Edge: requests completed in 90s | 0, 0, 0 (29–30 failed) | 12, 14, 16 (5–7 failed) | | NLC Edge: first request done | never | 6.3–7.0s | On Wi-Fi the two are about even. Under Edge, every HTTP/1.1 request on `main` hit its timeout while still opening a connection, and each retry started a fresh TCP+TLS handshake. The branch reuses one connection, so queries complete. Connection counts weren't measured on the device, since no proxy was in the path. Cold-cache demo on an iOS simulator (this branch, app caches and cached prefs wiped before launch): avatars, community icons, channel lists, public channel images, a DM image and custom emoji, video playback, reactions, and an image and video upload with progress all load. The DM image and clip are synthetic test media. https://github.com/user-attachments/assets/039d1a25-46f2-4e0e-b147-f96d0cfabd40 buzz-review-completed (agent review and device validation done; the human-test step is not applicable under Tom's standing exception for mobile changes) --------- 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> |
||
|
|
1fb33e4545 |
fix(relay): publish a git push only when refs changed; check the protocol by refinement (#9425)
## Summary This PR is stacked on #9064. It does two things to the Git-on-object-storage code in `buzz-relay`. It replaces the TLA+ model of the push protocol with one that states what the protocol is supposed to implement and checks that it does. It also changes the rule by which the relay decides whether to publish a push, because the new model shows that the rule #9064 introduces still publishes requests in which git accepted nothing. ### Relationship to #9064 The branch is `main`, then the two commits of #9064, then four commits of this PR: the specification work, the publish-rule change, a review fix merged from #9429, and a fix to `check.sh`. A merge of `main` follows them; it was needed because `main` changed `Justfile`, which #9429 also edits, after the branch point. The first two commits are #9064's, replayed onto current `main` without conflicts and otherwise unchanged. The base of this PR stays `main` because #9064's branch is about 200 commits behind it. Once #9064 merges, this branch can be rebased and the diff shrinks to the last four commits. Until then, review the commits after the first two. This PR keeps two of #9064's three changes as they are: the `published_at` stamp that orders kind:30618 events by the manifest chain, and `protect_landed`, which lets a landed conditional write stand when the serving lease lapses after it. It replaces the third, the publish rule. ### The publish rule #9064 publishes when git exits 0 and the status report has an `ok` line or no `ng` line. That handles a push in which one ref is updated and another refused. The report, however, is not evidence of what git did. - git exits 0 for a request that carries no commands, and for a push the pre-receive hook declined. A decline is reported only to a client that negotiated `report-status`. For a request with no commands, and for a decline when the client did not negotiate a report, there is no `ng` line. The relay then writes the pointer and emits a relay-signed kind:30618 event that names the caller as pusher, although the push policy approved nothing. - The report is read from a stream whose framing the client's capabilities determine, so the decision depends on parsing a client-shaped stream. The decision is now taken from the workspace. `push_disposition` compares the refs after receive-pack with the refs the workspace was hydrated from. Unchanged refs are answered with git's own output and nothing is published. Changed refs are published if git exited normally. Changed refs after an abnormal exit fail the request. The snapshot that is compared is the one `cas_publish` publishes, so git's per-ref report is true of the published state. The status-report scanner and its tests are removed, and the ref-name conflict case they covered moves into the table-driven test described below. Three smaller changes follow. The ref-snapshot parser now fails closed: output that was not UTF-8 used to read as an empty ref set and a malformed line was skipped, and either would publish a manifest that lacks refs. `manifest_changed` in `finalize_push` could not be false and is removed. Taking the snapshot before the publish decision moved its resource-limit error (snapshot larger than 4 MiB, or more refs than a manifest may hold) onto a path that answered 500; #9429 restores the 413 the publish path gave before and adds `snapshot_resource_limit_answers_413`. ### What the specification work adds - `docs/spec/GitRefDB.tla` is the specification: a ref database in which a push takes effect in one step, is applied only if authorized and only against the old values it names, and is reported truthfully. - `docs/spec/GitOnObjectStore.tla` is the protocol: several refs, an object graph with reachability, packs, compaction, the pre-receive hook, the publish decision, the default-branch setter, repository creation, and a reader. `git receive-pack` is represented by a stated contract rather than modeled internally. - The correctness statement is `Refines`: every behavior of the protocol is a behavior of the specification. TLC checks it together with the store invariants (fence, reader completeness, closure of published manifests). - `docs/spec/check.sh` pins TLC 1.7.4 by SHA-256 and runs 28 checks. Eight instances must pass. Nineteen seeded design errors must each be detected with the violation named for them. One coverage check confirms that a behavior the properties depend on (git reporting `ok` for a command that changes nothing) is reachable. A name passed to `check.sh` that is not a check is an error, so a misspelled selection cannot report success. - Both earlier publish rules are among the seeded errors: the original rule (exit 0 and no `ng`) and #9064's status-report rule. For each, TLC reports a publication the policy did not approve (`PublishIsAuthorizedChange`) and a violation of `Refines`. - `docs/spec/GitPushPath.qnt`, the Quint model from #9064 that covers the serving lease and the event timestamps, is extended. The publish rule becomes a parameter with three values, a request may carry no commands or come from a client without a status report, and a new invariant, `publicationChangesRefs`, states that a published manifest differs in its refs from the one it replaces. The implemented rule satisfies all four invariants; the status-report rule violates the new one with a zero-command request. - `docs/spec/lean/GitPointer.lean` is a Lean 4 proof, with no dependencies, of the pointer discipline for any number of clients, refs and objects: the fence, closure, that every pointer change is a legal step from the value it replaces, that the length of the pointer's predecessor chain equals the number of pointer writes, and that a successful conditional write implies no intervening write. - `.github/workflows/spec.yml` runs TLC and Lean when a file under `docs/spec/` changes. On this PR's first run the TLC job took 10 minutes 19 seconds and the Lean job 22 seconds. Quint is not run in CI. - `docs/git-on-object-storage.md` is revised to match. The push listing follows the implemented order. The verification section separates definitions, assumptions, results checked by TLC, results proved in Lean, results checked with Quint, evidence about the implementation, and limitations. The invariant names cited by code comments and by migration 0002 are kept as aliases in the TLA+ module. ### Assumptions and limitations The TLC results hold for the bounded instances in `check.sh`, not for arbitrary sizes. The Lean proof is unbounded but covers the pointer discipline only, not the refinement. The Quint results are from random simulation (2,000,000 runs of at most 14 steps) and a bounded symbolic check (8 steps, Apalache); neither is a proof for unbounded runs. All of them rest on the assumptions named in the document: the object store's conditional-write semantics, ETag equality implying body equality, no SHA-256 collision among the values that occur, the stated contract for `git receive-pack`, and the policy endpoint deciding authorization. The correspondence between the models and the Rust code is established by reading the code and by the tests below; it is not mechanized. The document states one assumption that #9064's ordering fix depends on and that no model covers (Assumption C1). A manifest whose parent carries no `published_at` (the empty manifest written when a repository is announced, or a manifest written before the field existed) is stamped with the writing instance's current time plus one second. Its event is selected over the parent's only if that value exceeds the parent event's `created_at`, which was taken from the clock of the instance that emitted it. This holds when one instance handles both, and otherwise when the writing instance's clock is not behind the other's by more than the time elapsed between the two events. The assumption concerns one write per repository. The document lists its removal as a proposed extension, for example by having the first push after an unstamped manifest read the stored event's `created_at` and exceed it. ### Related issue Related to #2621 (CI for the formal models). This adds the TLC job for the git model and a Lean job; it does not cover the Tamarin models or the NIP-PL scripts, so it should not close that issue. ### Testing Run locally on the restacked branch: - `docs/spec/check.sh`: 28 checks, each with the expected result (8 pass, 19 seeded errors violate the named property, 1 coverage witness reached). About three minutes on 16 cores; the largest instance has 6,773,286 distinct states. - `lake build` in `docs/spec/lean` (Lean 4.33.1): succeeds. The safety theorems depend only on `propext`, `Classical.choice` and `Quot.sound`. - Quint 0.33.0, `GitPushPath.qnt`: `quint run --main fixed --invariant safety --max-steps 14 --max-samples 2000000 --seed 0x1` reports no violation; `quint verify --main fixed --invariant safety --max-steps 8` (Apalache 0.62.1) reports no violation; each single-defect module produces the counterexample the document lists. - `cargo fmt --all -- --check` and `cargo clippy -p buzz-relay --all-targets -- -D warnings`: clean. - `cargo nextest run -p buzz-relay --lib -E 'test(/^api: |
||
|
|
642ce4165c |
fix(mobile): show shared chats under Solo (#9348)
## Summary Mobile puts agent-created and shared Solo chats under Team because it checks who created the chat. Show unarchived Solo chats the viewer belongs to under Solo and exclude them from Team. Preserve membership checks, guest read-only access, legacy chats, and owner-only composing. This changes mobile classification; it needs no relay changes or data migration. **Desktop:** <img width="557" height="695" alt="Screenshot 2026-11-11 at 10 00 07 AM" src="https://github.com/user-attachments/assets/d338fa19-7b6f-4b52-a3a6-061025216ed3" /> **Mobile (missing solo chats created by agents):** <img width="423" height="754" alt="Screenshot 2026-11-12 at 11 19 47 AM" src="https://github.com/user-attachments/assets/18b5da75-38e9-4cca-906c-d0a949cc8cbb" /> ### Code Changes Separate Solo list and unread classification from owner-only composing. Add regression coverage for shared and agent-created chats, Team exclusion, membership/archive filters, legacy chats, and ordinary sending from Solo. ### Related issue Related: #8963 adds mobile access controls and Solo sharing. This PR fixes the separate Solo/Team placement mismatch. No duplicate found in searches for mobile Solo issues and PRs. ### Testing Independent agent review found no blockers. Device testing could not be completed. Android emulators stalled at launch, iOS Simulator sign-in was blocked, and iPhone installation required capabilities unavailable to the Personal Team. Shared Solo placement and sending remain unverified in an authenticated app; before/after screenshots remain pending. Remaining manual checks: - Have an agent create a Solo chat and include the viewer as a member. - Confirm it appears once under Solo and is absent from Team. - Open it, send a message, and confirm owner-only Solo settings are absent. Generated with Codex --------- Signed-off-by: Evan Broderick <ebroderick@squareup.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: coder <e509b66797ba43fd355dc12801a0c1ba376b06924147c65abdafa7ababcf8e2e@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
8436d226c8 |
chore(release): release Buzz Desktop version 0.5.29 (#9456)
## Buzz Desktop release v0.5.29 - **Frozen main:** `41587e0284f277d13e5d3a40b169c1dcd54d2894` - **Reviewed candidate:** `69826ed72499fc3adb49c17a4e63302f4d256e26` - **Previous desktop release:** `desktop-v0.5.28` - **Proposed immutable tag:** `desktop-v0.5.29` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Release Automation <release-automation@users.noreply.github.com> |
||
|
|
41587e0284 |
feat(mobile): render messages with gpt_markdown 1.3.4 plusparse (#8945)
Message Markdown on mobile moves from gpt_markdown 1.2.1's legacy regex
pipeline to the CommonMark parser that 1.3 added ("plusparse"). Before
this, passing `inlineComponents` (now deprecated, removed in 2.0.0) kept
every message on the legacy pipeline.
### What changes for users
Rendering moves closer to desktop (react-markdown + remark-gfm):
- `_italic_` and `__bold__` work, backslash escapes and HTML entities
decode, and `[a [b] c](url)` labels are correct.
- Inline code follows CommonMark pairing: `"```npm install```"` in a
sentence is inline code, and four-backtick fences work.
- Setext headings, reference links, footnotes, `~~~` fences, `1)` lists
and CommonMark list/quote continuation render.
- HTML comments are hidden, as on desktop.
- `\[`, `\]`, `\(` and `\)` stay literal, as on desktop. Plusparse
hard-codes them as LaTeX delimiters, so they're written as entities
before rendering.
- Side effect: an unfenced `__init__` now renders as bold "init", and a
line over `---` becomes a heading, same as desktop.
Device benchmark (iPhone SE, profile build, 600 synthetic messages, 3
launches per build): scrolling spends 9% less time building, and frames
with more than 8 ms of build drop from 63 to 22. Jumping onto unbuilt
messages is 11% cheaper, and raster time is unchanged.
App size: the release app grows about 4.6 MB uncompressed (80.6 to 85.2
MB), mostly Dart code from the new math and highlighting packages.
### Before and after
Synthetic messages that reproduce differences found in real channel
history, rendered through `MessageContent` on main and on this branch:








### Demo
A scroll through synthetic messages covering common formatting
(headings, lists, quotes, links, highlighted code, a table, a task list,
escapes), recorded on an iPhone 17 Pro simulator running this branch:
https://github.com/user-attachments/assets/bdc9680c-d20b-41ec-8e1a-a5b51c602f9d
### How
- Mentions, custom emoji and `#channel` tokens are `InlinePattern`s.
Links go through `inlineLinkBuilder` into the existing `_buildLink`, so
chips, media links and taps are unchanged.
- Removed workarounds the parser makes unnecessary:
- the render-path escape shielding from #8925
- the `_MarkdownEscapeMd`, `_MarkdownLabelEntityMd` and `_MessageLinkMd`
components
- the zero-width prefix before a leading `#channel`
Plain-text previews and gallery helpers keep their own escape handling.
`normalizeBareLinks` stays because attachment and gallery detection
depend on its output. Bubble segmentation unescapes a link destination
itself before its imeta lookup, since content no longer arrives
shielded.
- Kept desktop parity where plusparse differs from GFM:
- a table typed directly under a caption line gets a blank line inserted
before it, since plusparse can't start a table mid-paragraph
- `> [!NOTE]` quotes render as quotes (`alertBuilder` returns
`asBlockQuote()`), since desktop has no alerts
- `mapMarkdownProse` now ends an unclosed backtick run at its paragraph.
Before, it could pair with a run in a later block and flip code and
prose for the rest of the message.
- Synthesized autolink labels escape backslashes instead of using
`\`, which the parser leaves undecoded in a URL-shaped label.
- Repo/PR/issue chips keep their own semantics node.
- Forum card previews clip at their 120 px cap instead of overflowing:
blocks now lay out as a column.
- Pinned to exactly 1.3.4. 1.4.0 renders HTML by default and themes
through the separate `material_ui` package, which would need a theme
bridge.
### Validation
- Full mobile suite passes and analyze is clean at
|
||
|
|
bd031a05af |
fix(acp): bound observer retention by bytes (#7987) (#9436)
Replaces #9366 on a branch in this repository and resolves its conflicts
with main.
## Summary
The ACP observer kept up to 1,000 raw events in both replay and live
buffers. Large valid messages could retain gigabytes before downstream
trimming ran.
Fit telemetry before either buffer retains it. Cap replay at 4 MiB of
serialized JSON and keep the existing live window. Its per-event limit
bounds live JSON below 64 MiB.
Preserve the CPU and locking improvements from #9381: prepare large
payloads and copy snapshots outside the replay lock. Finalize sequence
and timestamp with publication. Account for that final metadata,
including sequence digit-width changes, in the byte limit.
The tradeoffs are extra work per emit, shorter replay history for large
events, and elided oversized telemetry. Agent protocol messages remain
unchanged.
### Related issue
Fixes #7987. Existing publisher byte limits act downstream of these
buffers.
### Testing
The opt-in probe exercises the real in-process emit, snapshot and
stalled-subscriber paths:
```sh
CARGO_INCREMENTAL=0 CARGO_PROFILE_RELEASE_LTO=thin CARGO_PROFILE_RELEASE_CODEGEN_UNITS=4 \
cargo test --release -p buzz-acp --lib observer::tests::retention_probe -- --exact --ignored --nocapture
```
Compared baseline `a314975ce` with candidate `c2df3cb56` on macOS arm64,
Rust 1.98.1. The baseline observer, fitter, workspace manifest and
lockfile match the candidate's parent `8f3f4e266`. Both arms use
`opt-level=3`, thin LTO and four codegen units, with incremental
compilation and compiler-cache wrappers off. This profile is fixed for
the comparison; this PR does not change build settings.
Values are medians of three fresh-process runs per version and workload,
in alternating order. Peak RSS comes from `/usr/bin/time -l` around each
test executable; compilation is excluded. Each version was rebuilt
separately and its executable and workload identity verified.
| Workload | Peak RSS before → after | Emit time per event before →
after |
| --- | --- | --- |
| 10,000 × 1 KiB | 14.0 → 14.1 MiB | 0.47 → 0.89 µs |
| 1,000 × 60,000 bytes | 199.4 → 80.8 MiB | 8.54 → 22.53 µs |
| 1,000 × 1 MiB | 3,025.8 → 26.4 MiB | 123.50 → 307.89 µs |
For the 1 MiB workload, replay JSON fell from 1,000.2 MiB to 3.997 MiB.
Live JSON fell from 1,000.2 MiB to 5.91 MiB. Replay retained 676 fitted
events; the live subscriber received all 1,000.
Emit time includes payload construction. Peak RSS includes snapshot and
receive copies made by the probe. JSON budgets are not exact heap
limits. These local measurements show the memory/CPU tradeoff;
concurrent producer throughput and native app latency were not
benchmarked. See [the probe
instructions](
|
||
|
|
471ca6b1ae |
Tune release codegen for faster/smaller binaries (#9434)
Replaces #9424 with the same release-profile change on a branch in this repository so the review workflows can run. ## Summary Reduce release codegen units from 16 to 4 and enable thin link-time optimization (LTO). Local measurements show about **7% smaller binaries**, with a **14% increase in dependency-cached release rebuild time** (+3.1 seconds). Clean release build time was roughly flat. `codegen-units = 4` seems like a good balance. With thin LTO fixed, one unit cut stripped sizes a further 7–10%, but increased clean build time by 38% and cached release rebuild time by 67.5%. CPU and Activity trials showed no clear speedup. Fewer codegen units let LLVM optimize larger portions of each crate together. Lower counts typically favor runtime performance and smaller binaries over compilation speed. Thin LTO also enables optimization across crate boundaries. Runtime gains depend on the workload. Release keeps `opt-level = 3`, the highest numeric optimization level and Cargo's existing default. Development builds stay unchanged. Keep the custom `ci` profile at 16 codegen units with LTO off, preserving its existing settings. Sprig keeps its separate size-focused profile. CI jobs that use ordinary `--release` will use the new settings. These measurements cover `buzz` and `buzz-acp`, not the desktop bundle. The desktop app has its own Cargo workspace; its bundled CLI picks up this change after a pin update. Our end-to-end runtime trials were broadly flat; the measurements below demonstrate the size and build-time tradeoff. ### Related issue N/A — no duplicate release LTO/codegen PR found. This PR contains only the profile change and is independent of #9413. ### Testing #### Baseline versus four codegen units Compared identical source and lockfile at `b4de4479485fc3d57628ed509ed40c4278e63132`, using Rust 1.98.1 on an Apple M5 Max (18 cores, 128 GiB RAM). Both arms used `opt-level=3`, disabled incremental compilation and no compiler-cache wrapper. The baseline used LTO off and 16 codegen units; the candidate used thin LTO and four. | Median / artifact | Baseline | Thin LTO, 4 units | Change | | --- | ---: | ---: | ---: | | Clean release build (2 runs each) | 47.98 s | 46.71 s | -2.7% | | Dependency-cached release rebuild (3 each) | 22.55 s | 25.68 s | +13.9% | | No-op release build (5 each) | 0.469 s | 0.461 s | Roughly unchanged | | `buzz` executable | 14.516 MiB | 13.580 MiB | -6.4% | | `buzz-acp` executable | 14.580 MiB | 13.487 MiB | -7.5% | Build command: `cargo build --offline --locked --release -p buzz-acp -p buzz-cli --timings`. Set `CARGO_INCREMENTAL=0`, `CARGO_PROFILE_RELEASE_LTO=false|thin` and `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16|4` for each arm, with separate `CARGO_TARGET_DIR` directories. Clean runs used empty build directories in sequential ABBA order, with downloaded dependencies and filesystem caches warm. Rebuilds cleaned only `buzz-acp` and `buzz-cli` outside the timed region. Sizes are Cargo-built executables before the desktop packager's stripping step. Stripping copies with `xcrun strip -S -x` kept the size benefit: `buzz` fell from 12.550 to 11.805 MiB (-5.9%), and `buzz-acp` from 12.360 to 11.501 MiB (-7.0%). This does not measure a signed desktop installer. Both release executables passed `--help` smoke checks. Profile inspection confirmed the custom `ci` and `sprig` settings are unchanged. #### One codegen unit versus four A separate follow-up compared four versus one on `a314975ce7bf9aa00baebfef874b6e69474a95cf`, which includes the merged observer CPU/locking changes (#9381). Both arms used thin LTO, `opt-level=3`, the same Rust 1.98.1 toolchain and hardware, and identical source and lockfile. This is a separate source snapshot from the first comparison; the tables should not be combined into a three-arm comparison. | Median / artifact | Thin LTO, 4 units | Thin LTO, 1 unit | Change | | --- | ---: | ---: | ---: | | Clean release build (2 runs each) | 45.93 s | 63.35 s | +37.9% | | Dependency-cached release rebuild (3 each) | 22.38 s | 37.48 s | +67.5% | | Stripped `buzz` | 11.789 MiB | 10.923 MiB | -7.3% | | Stripped `buzz-acp` | 11.485 MiB | 10.320 MiB | -10.1% | | Activity p95 latency (median of 5 runs each) | 952 ms | 961 ms | No clear change | Both sets of binaries were stripped identically with `xcrun strip -S -x`. Repeat the build command above with `CARGO_PROFILE_RELEASE_LTO=thin` and `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=4` or `1`, retaining the same cache and ordering controls. All 60 CPU trials and 10 Activity trials passed. The CPU cases exercised production packing and queue ingestion functions; their differences were within run variation. Activity exercised real ACP subprocesses, observer publication, signing/encryption, the browser broker and production UI against a modeled relay. All 500 markers rendered exactly once, and all 2,250 heavy events were delivered. Its one-second publication interval limits visible latency; these local results are not production percentiles. Generated with AI assistance --------- Signed-off-by: Sanket Kanjalkar <sanketk@squareup.com> Co-authored-by: Codex <codex@openai.com> |
||
|
|
1fc6a71ef9 |
feat(desktop): add dismissible banner pointing to the new buzz app (#9379)
## Summary The Buzz 1.0 desktop app now lives in [block/buzz-app](https://github.com/block/buzz-app). This adds a banner to the 0.X desktop app that tells users, and lets them download the new app's installer without leaving the app. - A full-width banner sits under the top chrome: "A new Buzz app is here! This version is being retired, so grab the new one whenever you get a chance." - **Upgrade** downloads the installer for this machine inside the app. A dialog shows the progress, then the saved file's name and folder with **Show in Finder** (worded per platform) and **Done**. The file goes to the Downloads folder and is revealed in the file manager. It is never opened, mounted or run. - **Cancel**, Escape or the dialog's close button stops a running download and removes the partial file. - When no installer can be resolved for this machine (unsupported platform, or the manifest fetch fails or is slow), **Upgrade** opens `https://buzz.xyz` in the browser instead. - **More info** opens the [upgrade guide](https://github.com/block/buzz-app/blob/main/docs/upgrading.md) in the browser. - Dismissing (×, labelled "Remind me later") stores a timestamp in `localStorage`. The banner is read once per mount, so it comes back on the next launch (or community switch) once 24h have passed. - It is hidden in the huddle room window. ## Demo https://github.com/user-attachments/assets/1f1916e8-f32d-4ec0-8e60-fe7c85dd4a57 Recorded on macOS (Apple Silicon) from a native debug build with the real Rust backend: a full download, **Done**, then a second download cancelled part-way. - The network is throttled through a local proxy so the progress stays visible. Unthrottled, the installer arrives in about two seconds. - The app was launched with a scratch home folder, so the path in the dialog is a scratch path, and the Finder window that the reveal opens is left out of the recording. - Presses are accessibility presses, marked with an orange ring. The OS cursor is not shown. ## How the download works - **Which installer:** a Tauri command reports this install's key (`macos-arm64`, `macos-x64`, `windows-x64`, `linux-appimage` or `linux-deb`). The webview reads the matching entry from the [buzz-app download manifest](https://raw.githubusercontent.com/block/buzz-app/download-feed/downloads.json) once per banner mount. - **Validation:** Rust re-validates the URL on every call. It must be `https://github.com/block/buzz-app/releases/download/<tag>/<file>`, with a restricted tag and file-name character set and the extension this machine installs. The webview names a URL and nothing else. - **Redirects:** a dedicated HTTPS-only client follows at most 5 redirects, and only to `github.com`, `objects.githubusercontent.com` or `release-assets.githubusercontent.com`. - **Limits:** 512 MiB, 15 s to connect, 30 s for headers, 30 s without a byte, 60 min in total. One download at a time. - **Saving:** the body is written to `<name>.<random>.part` in the Downloads folder, then renamed to `<name>` (or `<name> (1)` and so on) without ever replacing an existing file. The partial file is removed on failure, on cancel and at app exit. A partial left by a crash or force-quit is removed on the next launch. - **Downloaded-from-the-internet marking:** macOS gets the `com.apple.quarantine` attribute a browser would write, and Windows gets the Mark of the Web (`Zone.Identifier`). If the mark cannot be written, the download fails and the file is not kept. - **Failures:** the dialog says what went wrong in plain words. Transient failures lead with **Try again**. Failures a retry cannot fix lead with **Download in browser**, which opens the validated installer URL. ## Testing - Unit: `deprecationBannerVisibility.test.mjs`, `newAppInstaller.test.mjs` (manifest and URL rules), `newAppInstallerDownload.test.mjs` (dialog state), and a jsdom test of the download hook. - Rust: `new_app_installer_tests.rs` covers URL validation, the redirect policy, limits, naming without overwrite, cancel and shutdown cleanup, launch cleanup, and the quarantine and Mark of the Web values. CI runs this crate's tests on Linux and on `windows-latest`, so the Mark of the Web test writes a real alternate data stream there. The quarantine test runs on macOS only, which CI builds but does not test. - Smoke: `tests/e2e/deprecation-banner.spec.ts` drives the banner, the download dialog, each failure's actions, cancel and the keyboard paths against the mock bridge. - Native, macOS arm64 debug build: the download was saved with `com.apple.quarantine` set and revealed in Finder; cancel removed the partial; the download kept running while the window was hidden; SIGTERM and a menu quit mid-download removed the partial; after a force kill the next launch removed it. Not verified by hand: - The Windows and Linux flows in a running app, including SmartScreen on Windows. - The Gatekeeper prompt when opening the downloaded DMG, and the macOS Downloads-folder consent prompt, which need a signed build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Matt Toohey <contact@matttoohey.com> |
||
|
|
3d3f9cf153 |
fix(mobile): keep publishing presence while the app is open (#9441)
Mobile stopped publishing presence on November 1. The heartbeat in
`PresenceNotifier` only runs while something holds `presenceProvider`,
and its last always-mounted watcher was the app-bar `ProfileAvatar`,
deleted as dead code in [#8880 dead code
cleanup](https://github.com/block/buzz/pull/8880). Since then the app
sends "online" only while Settings is open, stops when Settings closes,
and never sends "away" when backgrounded. Other people see a phone user
as offline (or stuck on whatever Settings last sent).
This keeps the provider alive from the signed-in shell, next to the
other app-wide `ref.listen` keep-alives in `app.dart`.
Found during a combined performance and network hunt on a physical
iPhone SE running main ([
|
||
|
|
2b428f56b8 |
feat(mobile): share into Buzz from the iOS share sheet (#9433)
Lets people share screenshots, screen recordings, photos, URLs and text from any iOS app into Buzz. Today Buzz doesn't appear in the share sheet at all. - **Plain Buzz icon:** opens a new Solo chat with the items in its composer. - **Suggested conversation:** iOS offers channels and DMs the person recently sent top-level messages in. Choosing one opens that conversation with the items in its composer. When the conversation is already open, the share goes to that page's composer instead of opening a second one; if another page covers it, Buzz stays where it is and shows "Go back to that conversation to finish sharing", and the items join the draft when the person goes back. - The share extension has no UI and sends nothing. It copies the items into the app group and opens Buzz, which prefills the normal composer, so drafts, attachments, mentions and failed-send handling all stay the app's (see [the ADR](https://github.com/block/buzz/blob/mobile-share-extension-design/docs/adr/0003-share-hands-off-to-the-conversation.md)). - If the conversation already has a draft, Buzz asks whether to add the shared items to it or replace it. - A share to a conversation that's no longer available (a removed community or channel, or an unreadable share) shows "That conversation isn't available". A share while signed out shows "Sign in to share". Suggestions come from an outgoing `INSendMessageIntent` the app donates after a top-level send or a new Solo chat. The notification extension no longer donates incoming messages, so receiving a message never makes a conversation a suggestion; it still turns the push into a communication notification with the sender's avatar. Both use the same conversation identifier, now the plain community and channel IDs instead of a hash, because the extension needs to hand the identifier back to the app. | Before | After | | --- | --- | | <img alt="Share sheet without Buzz" src="https://github.com/user-attachments/assets/452fac1b-7e87-44c3-a442-099f78d0e07b" width="300"> | <img alt="Share sheet with Buzz" src="https://github.com/user-attachments/assets/54b9775d-29b4-49a5-8ab9-5fda8718a02f" width="300"> | | Buzz icon: new Solo chat | Suggested DM with a draft | After Add | | --- | --- | --- | | <img alt="New Solo chat with the shared photo" src="https://github.com/user-attachments/assets/c5da4d8b-1dd8-4e30-9e92-9dba515b3623" width="250"> | <img alt="Add to your draft prompt" src="https://github.com/user-attachments/assets/2f879f8a-3b9f-48e0-b963-2828e90dc9fe" width="250"> | <img alt="Draft with the shared link and photo added" src="https://github.com/user-attachments/assets/12832006-85c5-4447-98b5-62932552c931" width="250"> | https://github.com/user-attachments/assets/b9d380fa-e802-4ed7-89a8-592a03784c8f ### Regressions - Nothing changes for people who never use the share sheet, except that sending a top-level message or starting a Solo chat now makes one local `INInteraction.donate` call (skipped while the age restriction is on, which still purges every interaction). - Communication notifications look the same. Dropping the incoming donation removes work (and a deletion fence) from the notification extension's time budget. - The share hand-off reuses the existing deep link dispatcher. A share link is a new link type; notification and message links take the same paths as before. ### Validation - Added tests: - Dispatcher hand-off: a share to a suggested channel opens it with the shared text and files; shares to a removed community, an unlisted channel, an unknown manifest version or a missing manifest show the error and delete the pending share; share links accept only the extension's UUID ids. - Composer delivery: with an existing draft, Add appends the shared text on a new line and attaches the file, Replace clears the draft first, and the app group copy is deleted either way. - Notification presenter: specializes the notification from the intent, or falls back to the ordinary content when that throws (replaces the donation and deletion-fence tests). - Full mobile Flutter suite and BuzzPushKit Swift tests pass. - Automated app run on an iPhone 17 Pro simulator (iOS 27, dev relay test community), recorded above: sent a DM, shared a photo from Photos with the Buzz icon from a cold start (new Solo chat with the photo attached), then handed a link and photo to a DM that had a draft and chose Add. Also checked that a share's pending files are deleted after delivery. - Limitation: the iOS simulator never shows share-sheet conversation suggestions (it has no `coreduetd`), so the suggested-DM step in the video is staged by writing the same pending share and link the extension writes. Choosing a real suggestion still needs a check on a device. - Known gaps, all confined to sharing: suggestions have no avatar image yet; a suggestion for a conversation left after signing out shows "isn't available"; choosing Later in the unfinished-chat dialog keeps the share until the next new chat; and a pending share the app never receives (for example when Buzz doesn't open) stays in the app group. ### Before merge - [x] On a real iPhone, choose a suggested conversation in the share sheet and confirm the shared items open in that conversation's composer (the simulator can't show suggestions). Waived before merge: the managed test iPhone shows no share-sheet suggestions for any app, so this will be checked on a personal phone after landing. --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
3c0adc1882 |
fix(mobile): qualify every namesake mention and keep authored mention casing (#9325)
## problem When a message mentions two different identities that share a display name, the mobile composer qualified only the later selection as `@Name (<64-hex>)` and left the first one bare. A qualified occurrence denies the plain name on render, so the first mention rendered as plain text on mobile and desktop. `MessageContent` also displayed every mention in the lowercased binding key rather than its authored spelling. ## change - `mention_bindings.dart`: - `selectedMentionLabel` qualifies a selection whenever another selected identity shares its name, without regard to case. - `namesakeRequalifications` and `renameMentions` qualify earlier plain selections. Each occurrence keeps its authored spelling, and occurrences in code stay as typed. - `compose_bar/helpers.dart` and `compose_bar_widget.dart`: - Selecting a namesake qualifies earlier same-name mentions before the new label is inserted, keeping the caret in place. - Occurrences inside code that the composer styles are skipped: single-backtick spans and closed triple-backtick fences, found by the same walk the editor uses to draw them. A key whose occurrence stays in code remains bound beside its qualified label, so its recipient still sends. - `agent_mention_prefill.dart`: automatic addressing qualifies every same-name agent it inserts. - `message_content.dart`: mentions render in their authored spelling, and bindings still resolve case-insensitively. - `docs/mention-editor.md`: describes the behavior, including that Markdown code forms the composer does not style (tilde fences, indented code) are qualified like prose. ## tests - `mention_bindings_test.dart`: requalification, spelling preservation and code exclusion. - Composer journeys: - picker selections qualify both namesakes and send both keys; - styled code (fences, list fences, spans, spans after bold or italics) stays as typed while surrounding prose is qualified; - a selection left only in code keeps its recipient; - automatic prefill of two same-name agents qualifies both. - `message_content_exact_mentions_test.dart`: authored casing with case-insensitive resolution. - `just mobile-check mobile-test` passes. - Real relay: composed bodies exchanged through a local relay render as correctly targeted chips in desktop and in Flutter `MessageContent`. --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: goose-wayne <ae6b377e5b6d090ee22558d19246413a5731628a127e25371111badfd4861535@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> |
||
|
|
bf3bdd0eae |
fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437)
The two follow-ups from the pre-review of [#9432 GIF frame timing](https://github.com/block/buzz/pull/9432), rebased onto main now that it has landed. **iOS reaction details sheet.** The sheet decodes custom emoji natively (`NativeMessageImageLoader.decode`) and timed each frame as `max(0.02, delay)`, so with [#9432 GIF frame timing](https://github.com/block/buzz/pull/9432) a GIF with 0 or 1 cs delays plays at 100 ms per frame in the timeline chip but 20 ms per frame in the sheet opened from it. The decoder now shows frames of 10 ms or less for 100 ms, as browsers and `MediaImageProvider` do, and keeps longer frames as written. The 20 ms floor goes with it: browsers keep 11 to 19 ms frames as written too. **Provider-level test.** The existing tests call `withBrowserFrameTiming` directly, so removing the provider's call to it left the suite green. A new test resolves a GIF through `MediaImageProvider` with a mock HTTP client and pumps the scheduler's app frames at explicit timestamps: the 10 ms first frame is still showing at 50 ms and is replaced at 100 ms. ### Regressions None expected. The sheet already summed the frame delays into one `UIImage.animatedImage` duration, so only the per-frame value changes, and only for frames of 10 ms or less, which ran at 20 ms before. Still images and longer frames decode as before. The Dart test drives frame callbacks with explicit timestamps rather than waiting on wall-clock time. ### Validation Added tests: `NativeMessageImageLoaderDecodeTests` in RunnerTests (a 0, 1, 2, 5 cs GIF decodes to a 0.27 s animation; a one-frame GIF stays still) and `withBrowserFrameTiming is applied to the frames MediaImageProvider plays` in `media_image_test.dart`, which fails when the provider's call is removed (the second frame shows at 50 ms). Full mobile suite: 4,780 tests passed. `flutter analyze`: no issues. RunnerTests `NativeMessageImageLoaderDecodeTests` and `NativeReactionDetailsTests`: all five pass on an iPhone 17 simulator (Xcode build-for-testing of the Debug simulator app). No full-app run: the change is one delay value in a decoder and is covered by the unit tests above. Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: judge <7615ec4b7ddf99f0bb48e913297f99f8f772d289a1c9ef54e1d906dd55a93ba8@sprout-oss.stage.blox.sqprod.co> |
||
|
|
0bbcf3e7c2 |
fix(mobile): play short-delay animation frames at browser speed (#9432)
Some animated custom emoji play far faster on mobile than on desktop. Browsers show any animation frame with a delay of 10 ms or less for 100 ms. Firefox started this ([bug 207059](https://bugzilla.mozilla.org/show_bug.cgi?id=207059)). Chromium copied it (decoder comment: "use a duration of 100 ms for any frames that specify a duration of <= 10 ms"), and so did WebKit ([changeset 73295](https://trac.webkit.org/changeset/73295/webkit)). Flutter uses the delay as written ([flutter/flutter#130684](https://github.com/flutter/flutter/issues/130684)), so a GIF with 0 or 1 centisecond delays runs at the display refresh rate on mobile and at 10 fps on desktop. `MediaImageProvider` now wraps multi-frame codecs so frames of 10 ms or less get 100 ms. Every other frame keeps its own delay. Still images are passed through unchanged. Custom emoji, avatars and attachments all render through this provider. **Tests:** `flutter test test/shared/relay/media_image_test.dart` passes (24/24), and `flutter analyze` reports no issues on the changed files. The new test decodes a GIF with delays of 0, 1, 2 and 5 cs and expects 100, 100, 20 and 50 ms. It fails when the wrapper is removed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
279a31daac |
buzz-search: read profile names without the profile_name_fields function (#9397)
## What
This is phase 1 of 2 for retiring the plpgsql function
`profile_name_fields(text)`. Its only caller, profile name search
(`search_profile_names`), now gets `name` and `display_name` from an
inline SQL expression (`PROFILE_NAME_FIELDS` in
`crates/buzz-search/src/query.rs`) instead.
The function's exception handler made unreadable kind:0 content have no
names rather than fail the search. The inline version keeps that
guarantee in two steps:
- **`pg_input_is_valid(content, 'jsonb')`** rejects the soft parse
errors: a `\^@` escape, a lone surrogate, a huge number, and malformed
JSON.
- **A bracket bound for deep nesting.** PostgreSQL's JSON parser is
recursive. Past about 10k levels at the default `max_stack_depth`, it
raises `stack depth limit exceeded`, and `pg_input_is_valid` does not
catch that. I confirmed this on PG 17: depth 10k is fine and 20k raises.
So content with more than 4096 `[` and `{` is never parsed and has no
names. The count bounds the depth. Ingest already rejects kind:0 content
nested deeper than 128 levels (serde_json), so this only matters for
content stored another way.
`OFFSET 0` keeps the parse from being copied into each reference to
`doc`, so each candidate row is parsed once, plus one validity pass. The
plpgsql function also parsed each row once.
**Behavior change (edge only):** a kind:0 with more than 4096 brackets,
even inside strings, is no longer findable by name. Real profiles don't
come close, and a `picture` data URL is base64 with no brackets.
`pg_input_is_valid` needs PostgreSQL 16 or later. CI runs 16, and the
dev compose runs 17.
**Phase 2 (not here):** a migration and desired-state change drops the
function, its `schema/functions/` file and its `schema.sql` entry once
this release has shipped.
## Tests
- `profile_names_survive_unparsable_content` already stores a `\^@`
profile, a lone-surrogate profile, a `1e1000000` profile and a 60k-deep
profile next to a plain one, and requires the search to return only the
plain one. It now also covers the bound itself: a profile with exactly
4096 brackets, all but one inside a string, is still found.
- Must-fail mutants, each run on Blox: bracket bound removed (the search
fails with a stack overflow), validity check removed, bound tightened by
one, and the string-type check loosened.
## What got simpler
Profile search no longer depends on a database function. Name extraction
lives next to the rest of the search SQL it feeds.
## Verification
Blox, at `be0db303ea90033cc2c37ac8f8f07c27f4b08e31` (clean tree):
| Gate | Result |
|---|---|
| `cargo fmt --all --check` | pass |
| `cargo clippy -p buzz-search --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-search --lib` | 4/4 |
| `scripts/postgres-test-run.sh -p buzz-search --tests -E
'test(/profile_names/)'` | 4/4 |
| Must-fail mutants (no bound, no validity check, bound tightened by
one, non-string names allowed) | 4/4 killed |
Probe on PostgreSQL 17.11: `pg_input_is_valid` returns false for `\^@`,
a lone surrogate, `1e1000000` and truncated JSON; nesting depth 10k is
valid; depth 20k raises `stack depth limit exceeded`. CI runs the full
Postgres lanes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
buzz-review-completed
---------
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>
|
||
|
|
745f7dac7c |
buzz-db: build the community deletion lock key in Rust (#9396)
## What This is phase 1 of 2 for retiring the SQL function `community_deletion_lock_key(uuid)`. Every relay caller now builds the lock name in Rust, with `buzz_db::deletion::community_deletion_lock_key(CommunityId) -> String`. It then locks with the built-in `hashtextextended($1, 0)`, the same pattern the other advisory locks here use (`channel_ttl_lock_key`, `push_gate_lock_key`). The string is byte-for-byte the one the function hashes (`'buzz-community-deletion:' || target::text`), so the lock is identical. Old and new relays, and the fence functions still in the database (`community_write_allowed`, `assert_community_write_allowed`), keep excluding each other during a rolling deploy. - **Production callers:** exclusive `lock_community_deletion`, shared `lock_community_deletion_shared`, and the bulk shared lock in `bind_nip_fi_issuers_to_active`. The bulk lock now binds a `text[]` of keys, sorted by community id in Rust and locked `WITH ORDINALITY`. That keeps the old `ORDER BY id` lock order, because `Uuid` and Postgres `uuid` both compare bytewise. - **Startup check:** `validate_serving_catalog_on` no longer requires the function to exist, so the phase-2 drop can't make this release refuse to serve or delete. - **Tests and docs:** test lock holders use the helper. `docs/database.md` gives repair scripts the inline expression, which keeps working after the drop. - The test-schema shims in `api/bridge.rs` and `handlers/ingest.rs` stay. Test schemas still copy the fence functions that call the function. **Phase 2 (not here):** a migration and desired-state change drops the function after this release is fully deployed and the fence functions are gone (#8323). It also removes the two test shims. ## Tests - `deletion::postgres_tests::community_deletion_lock_key_matches_the_sql_function` checks that `hashtextextended(rust_key, 0)` equals `community_deletion_lock_key(uuid)` for the nil UUID, the max UUID and 64 random ones. - `migration_schema_fence_objects_are_optional_for_serving_and_deletion` already drops the universal fence objects by hand. It now also drops `community_deletion_lock_key(UUID)`. With both present and absent, it runs the startup and readiness checks, serving leases (shared lock), `bind_nip_fi_issuers_to_active` (bulk shared lock) and `lock_community_deletion` (exclusive lock). Must-fail mutants, each run on Blox against its guarding test: a changed key prefix, the shared, exclusive and bulk locks reverted to the SQL function, and the startup check requiring the function again. ## What got simpler No relay code path calls a database function to build this lock any more. The key has one definition in Rust instead of one in SQL plus about 20 SQL call sites. One fewer object stands between the schema and declarative-only management (`docs/database.md` Rule 1). ## Verification Blox, at `3020a44148d942f1502395640730cd0548a76400` (clean tree): | Gate | Result | |---|---| | `cargo fmt --all --check` | pass | | `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` | pass | | `cargo nextest run -p buzz-db --lib` | 214/214 | | `scripts/postgres-test-run.sh -p buzz-db --lib` over `deletion::postgres_tests`, `nip_fi_binding::postgres_tests` and the reaction and channel-member lock tests | 101/101 | | Must-fail mutants (prefix, shared, exclusive, bulk, startup check) | 5/5 killed | CI runs the full Postgres lanes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed 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> |
||
|
|
630922a9e1 |
fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417)
## Summary Fixes to `buzz auth desktop pair` and relay selection: - **`pair` skips desktops that aren't running.** A desktop that exited without removing its advertisement no longer counts toward "several Buzz desktops are running". It uses the same running test as `probe`, where only a transport failure means not running. - **`--relay` is global**, so `buzz auth desktop pair --relay …` works and the flag shows in subcommand help. - **A bare host means `https://`** for `--relay` and `BUZZ_RELAY_URL`. An explicit `http://`, `ws://` or `wss://` stays as typed. - **`pair` with no relay sends `[client]`.** The desktop pairs for its active community and returns the bound relays, which the CLI stores. An older desktop answers `invalid_request`, which the CLI reports as "this Buzz can't pick a community; pass --relay". - **`docs/local-credential-provider.md`**: covers the one-parameter `pair`, `unavailable` when no community is active, the older-provider fallback, skipping stale advertisements, and a lapsed sign-in keeping the pairing instead of revoking it. - The existing `concurrent_pair_and_unpair…` test now fails after 60s instead of hanging when the held pair request never reaches the mock desktop. - The CLI tests that run the real `lsof` same-user check now take turns. A nextest test group, `desktop-same-user-check`, serializes the whole `desktop_provider::` module for the pre-push hook. Most of its tests reach the check, and the few that don't run serially too. A test-only lock around the check does the same in `cargo test`. Waiting for a turn doesn't count against the request timeout. The check and its 5s limit are unchanged. `list_tells_a_stale_advertisement_from_a_rejection` keeps the in-cap `retry_after` of 30 and times from the desktop's first answer, so waiting for a turn isn't counted. The desktop half is block/buzz-app#3386. ## Testing - New tests: `pair_skips_a_desktop_that_is_not_running`, `an_older_desktop_asks_for_a_relay`, `relay_flag_is_global_and_a_bare_host_means_https`, plus updated relay-selection cases. - `cargo test -p buzz-cli --locked -- --test-threads=4` passes: 689 tests, both with default threads and with `--test-threads=4`, and under the pre-push hook's nextest run. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
8f3f4e266a |
test(mobile): run push tests without a gateway dart-define (#9423)
A plain `cd mobile && flutter test` failed 11 push and notification tests on a clean checkout of main, while CI passed. Agents running the full suite kept reporting them as "pre-existing, also fails on main", which hides real regressions in the same files. ### Root cause Push is gated on the compile-time `BUZZ_PUSH_GATEWAY_URL` define (`Env.pushGatewayConfigured`, since [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) made the gateway optional). Without it the app is a push-less build: `readBuzzPushEndpointGrants()` returns no grants and `buzzPushLifecycleEnabled()` is false. CI's mobile shards, `just mobile-test` and the lefthook pre-push lane (which runs `just mobile-test`) all pass `--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`; a bare `flutter test` passes nothing. Each of the 11 was checked against that: - `notification_toggle_test` (5 cases) and `push_lease_revocation_outbox_test` "hosted relay assertion": no endpoint grants without a gateway (`Bad state: No element` at the grant lookup, `No endpoint grant exists for push lease tombstone`). - `push_bridge_test` "reads and exposes persisted endpoint grants" and "debug enrollment carries the configured relay and gateway URLs": the bridge returns `[]` before calling native (`Expected: an object with length of <1>, Actual: []`). - `push_bootstrap_test` "default push requires relay capability" and "new community starts registration": the lifecycle is disabled, so registration never starts (`Expected <1>, Actual <0>`). - `push_bridge_test` "development push gateway matches the compiled configuration": a stale test. It expected a `https://push.buzz.xyz` default that [#7261 configurable gateway](https://github.com/block/buzz/pull/7261) removed from the app; under CI's define both sides equal `push.example`, so it could never fail there. The same files pass on the same Mac with the define, so nothing is environmental. The `Unexpected native call takePendingNotificationResponse` log is unrelated noise: the toggle test's native mock didn't answer that call, which logs (and is caught) in CI too. ### Fix Tests get the gateway from a test hook instead of the define. `Env.pushGatewayUrl` becomes a getter over the compiled value with a `@visibleForTesting` `debugPushGatewayUrlOverride` (the same pattern as `debugUriStreamOverride`), and `test/flutter_test_config.dart` sets it for every test file. `push_unconfigured_build_test` sets it to `''`, so it runs in the main suite instead of being skipped there and run separately. CI, the Justfile, `scripts/flake-check.mjs` and the AGENTS/README check commands drop the define, so CI, `just mobile-test`, pre-push and a bare `flutter test` run the same thing. I chose this over documenting a canonical command because the define can't be made automatic: `flutter test` has no config file for defines, so any doc fix still leaves the obvious command red. A test hook makes every invocation agree. The stale test is replaced by one in the push-less file that binds the production read: with the override cleared, `Env.pushGatewayUrl` must equal the compiled `BUZZ_PUSH_GATEWAY_URL`. Now that CI compiles without the define, it fails if the app regains a built-in gateway default, which the old test never could. The toggle test's mock answers `takePendingNotificationResponse` with no pending response. What gets better: one test mode everywhere, and the push-less tests run in every run. Nothing gets worse: production reads the same compiled value (the override is never set outside tests, and an `aot-snapshot` probe shows AOT still folds the getter to the compiled constant and drops push-only code when the define is absent), and every test still runs in the push-capable mode CI used before. ### Validation - On a fresh origin/main worktree with the Hermit toolchain, a bare `flutter test` failed exactly these 11; the same files passed on the same machine with the define. - After the fix, the pre-push `just mobile-check` and `just mobile-test` (now a bare `flutter test` plus the recipe and secp256k1 checks) pass, with the push-less tests running instead of skipped. `scripts/flake-check.test.mjs` passes. - Falsifiability: re-adding a `defaultValue` to the compiled gateway fails the new no-default test, and removing `flutter_test_config.dart` brings back the other 10 failures. - Unrelated: an earlier full run also failed `community_provider_test` "discard orphan snapshots and image caches", which waits 500 ms for a background directory delete. The Mac was running other full suites at the time; the file passed 8 of 8 runs alone, with and without the define. It is not touched here. 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> |
||
|
|
5bfbda4c33 |
fix(mobile): never pop Home when archiving from channel settings (#9387)
Archive, Leave, Unarchive and Delete in channel settings wait for the relay, then close the settings sheet by popping whatever route is on top of the root navigator. If the user closes the sheet themselves during that wait (for example: long-press a chat on Home, Archive, confirm, then tap the close button), that pop lands on Home instead. The navigator ends up empty: a blank white or black screen with no controls and no way back short of killing the app. ### Change - **Close only the route that offered the action.** (Main has since gained the same guard; after merging main, this part of the diff is a comment plus the tests below.) After the action completes, the settings sheet or page closes itself only if it is still on top, with its "close the conversation too" result, as before. If the user opened something over it meanwhile (for example a member's profile), it stays open under that route, which keeps working. If the user already dismissed it, nothing is popped. The sheet still closes on its own after a successful archive. - **Home can no longer be popped.** The app's root route refuses pops. A stray pop that reaches it is reported through `FlutterError.reportError` (which fails widget tests) and ignored, so no other caller can leave an empty navigator either. Local-history pops on the root route (a drawer or persistent bottom sheet) still close normally. The root route still rebuilds from the current auth state, so sign-in and sign-out transitions are unchanged. ### What changes for the user Only the case where a settings action finishes after the user moved on: - Settings dismissed mid-flight: before, the completion popped whatever was on top (Home, the conversation, a thread or a profile). Now nothing is popped. - Something opened over settings mid-flight: before, the completion popped that new route and left settings open. Now that route stays, and settings stays open under it, as on main. That settings page shows the channel as it was before the action (for example still offering Archive after an archive, or a channel that is gone after Delete or Leave), as on main. Settings following lifecycle changes is part of the follow-up below. - Delete or Leave from the pushed settings page, then back out before the relay answers: before, the stray pop happened to close the conversation. Now the conversation stays open until the user leaves it (Leave and Archive show the read-only notice; a deleted conversation keeps showing its last state). Closing it reliably needs the conversation to notice its channel is gone, which is a follow-up. Pops that reach Home were always a bug and are now refused. Every other pop (sheets, pushed pages, system back above Home) takes the same path as before. ### Validation Added tests: - Channel actions sheet journey: confirm Archive with the relay call still pending, close the sheet, then let the archive finish. Home stays. A second pass leaves the sheet open and checks it closes by itself once the archive completes. Fails on the old code with Home popped. - Conversation journey: open settings from a conversation, confirm Archive with the relay call pending, open a member's profile, then let the archive finish. The profile stays open and current, and its Message action still opens the DM. Fails if the completion pops the top route, and fails if it removes the covered settings page (the profile loses the context it navigates from). - Root route: normal push and pop still work, a drawer on Home closes on back, and popping Home is refused, reported, and Home stays on screen. Full mobile test suite and `mobile-check` pass, including after merging main. Automated app run at [mobile-v1.1.0-rc.3 |
||
|
|
a314975ce7 |
perf(acp): reduce observer CPU and lock overhead (#9381)
## Summary The Agent Client Protocol (ACP) observer records agent activity for the owner's viewer. Sizing currently allocates JSON strings and rescans whole frames for each trimmed field. Count JSON bytes directly, reuse fitted sizes, and count changed fields when cheaper. Skip raw-message copies for disabled observers and copy replay payloads outside the mutex. Keep sequence numbers, timestamps, replay insertion, and publication under one lock to preserve replay/live ordering. This adds one `Arc` allocation per replay entry, and snapshots pin entries until copying completes. Replay retains the existing 1,000-event limit. ### Related issue Related to #7987. This PR targets `main` independently of #9366, which owns the byte-budget fix. The changes overlap in observer plumbing, so the second PR to land will need a rebase. ### Testing The new ignored probe exercises the production publish queue. Timing includes template clones and queue ingestion, before encryption. Five alternating fresh release processes per arm/case compared `75d38b6c2` with this commit on a busy host. Every pair retained identical queue bytes and event counts. ```sh cargo test --release -p buzz-acp --lib observer::perf_tests::publish_queue_probe -- --exact --ignored --nocapture ``` Set `BUZZ_OBSERVER_PROBE_EVENTS`, `BUZZ_OBSERVER_PROBE_BYTES`, and `BUZZ_OBSERVER_PROBE_FIELDS` for each row. Medians: | Events / string bytes per event / fields | Before | After | | --- | ---: | ---: | | 200 / 1 MiB / 64 | 5605 ms | 228 ms | | 1,000 / 1 MiB / 1 | 714 ms | 352 ms | | 10,000 / 1 KiB / 1 | 13.01 ms | 6.90 ms | A separate 20-trial integration run used the same production Chromium app build (`block/buzz-app@128cd4c2`) and four real ACP subprocesses. Three peers emitted 225 synthetic messages over five seconds, each with 1 MiB across 64 strings. A fourth emitted 50 markers, 100 ms apart. Each arm/mode used five fresh processes and browser contexts, with alternating arm order. The real observer, publisher, encryption/signing, browser broker, and Activity UI ran through a modeled relay transport. Marker timing used one controller clock, from release through transcript DOM commit plus two animation frames. Observer timestamps were not the timing origin. Observation-enabled medians: | Metric | Before | After | | --- | ---: | ---: | | Rust CPU time | 4.49 s | 0.45 s | | Marker-to-render p95 | 4471 ms | 1018 ms | | Sampled peak RSS | 342.3 MiB | 296.9 MiB | All 500 enabled-mode markers rendered exactly once; all 2,250 large events were delivered with the existing payload elision. Observation-disabled CPU stayed roughly flat (0.13 s to 0.12 s). The controlled publication-order regression also fails original `main` and passes this commit. These are stress-workload measurements, not a general app speedup. Native hosting and deployed-relay performance remain unmeasured. The publish cadence remains one frame per second. No CI timing threshold was added. Generated with Codex Signed-off-by: Sanket Kanjalkar <sanketk@squareup.com> Co-authored-by: Codex <codex@openai.com> |
||
|
|
ce42853982 |
ci: fix main — stale RunCause call sites and Playwright on ubuntu-26.04 (#9418)
## Summary
Two separate CI breaks are hitting every PR right now. Each one blocks a
required check, and neither fix can go green on its own, so this PR
fixes both.
**1. Rust test targets stopped compiling on main at fc8c8022.** #9227
added a required `RunCause` argument to `create_workflow_run` and a
`may_direct_agents` flag to `ActionSink::send_message`. Two tests that
merged around the same time still used the old signatures:
- `attach_rejects_run_from_another_workflow` in
`crates/buzz-db/src/store/workflow.rs` (from #8317)
- the agent-DM refusal test in `crates/buzz-relay/src/workflow_sink.rs`
(from #9371)
Each PR passed CI on its own base. Together they break Rust Lint, Unit
Tests, Windows Rust and the Desktop E2E Relay artifact build (main CI
run 43291564834). The fix passes `RunCause::Schedule` and `true`,
matching the neighbouring tests. Neither test is about run cause or
agent mentions, so neither assertion changes.
**2. Desktop E2E Integration fails on GitHub's new Ubuntu 26.04
runners.** `ubuntu-latest` is rolling to 26.04, and Playwright 1.60 (our
lockfile) fails with `Playwright does not support chromium on
ubuntu26.04-x64`. Playwright 1.61 is the first release that knows
ubuntu26.04. This PR bumps `@playwright/test` to `^1.65.0` in
`desktop/`, `web/` and `admin-web/` and regenerates `pnpm-lock.yaml`. It
also pins every `ubuntu-latest` job in `ci.yml` and `_ci-*.yml` to
`ubuntu-26.04`, so the next runner image change arrives in a reviewed
diff. macOS and Windows jobs are unchanged.
The newer Chromium bundled with Playwright 1.65 changed two smoke specs.
It reports Tailwind's `items-end` as `align-items: flow-end`
(`doctor-states` now accepts `flex-end` or `flow-end`), and it draws the
textarea resize grip differently, so the
`workflow-template-variable-autocomplete` baseline is refreshed from the
ubuntu-26.04 CI output.
It also exposed a timing race in `profile.spec.ts` "owned agent profile
stays in parity": when the Message click lands while the profile panel
is still settling, Playwright's scroll-into-view retry scrolls the
profile body, which hides the owned-agent action row (aria-hidden +
inert) for good. The test now pins the body to the top and waits for the
row before clicking (12/12 repeats plus the full profile spec pass on
Linux).
## Verification
- `cargo clippy -p buzz-db -p buzz-relay --tests -- -D warnings` is
clean on
|
||
|
|
60b7fb8178 |
fix(mobile): keep shortcut taps and the member picker from wedging (#9388)
Follow-up to review notes on [#9360 Home Screen shortcuts](https://github.com/block/buzz/pull/9360). These are fixes for rare races, with no visible change. - **The member picker can't wedge.** UIKit ignores a dismiss that arrives mid-transition (the sheet still animating in, or during an interactive swipe-down) and never runs its completion. The picker coordinator queues every later present behind that completion, so New DM and Add members then opened nothing for the rest of the session. Dismiss now waits for the transition to finish, then dismisses whatever is still presented. - **A shortcut tap reaches Dart once.** The native bridge both buffered a tap and sent it, so a tap during cold launch could arrive twice and run again after Home had already handled it. Now a tap is buffered only until Dart first takes the pending one, and after that it is sent directly. - **Signing out drops a waiting tap.** Removing Home clears a tap that is still waiting (for example on a Bestie that is still loading), so it can't run in the next account's Home. Not included: carrying the Bestie's pubkey in the shortcut item, so a stale "Message Alice" falls back to Agents rather than opening the current Bestie. It's optional, and it would change the bridge's payload shared with the lock screen widgets in [#9373 lock screen widgets](https://github.com/block/buzz/pull/9373). ### Validation - The Home shortcut journey gains a step: a tap waiting while the app is inactive is cleared when Home goes away. The step fails with the teardown guard removed. - Automated simulator run (iPhone 17 Pro, iOS 27, signed in to a Builderlab test community), driving the app icon's quick actions on the Home Screen: - New DM from a conversation opens the picker. New DM again, with the picker already open, replaces it. New chat over the picker closes it and opens a draft, and New DM after that opens the picker again (it doesn't get stuck). - Message (Bestie) opens the Bestie conversation. - Cold launch through New chat opens exactly one New chat screen. Back returns to Messages. buzz-review-completed 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> |
||
|
|
defc2d5ea3 |
fix(mobile/ios): close notification code blocks only at line-ending fences (#9349)
Follow-up to [#9346 notification message text](https://github.com/block/buzz/pull/9346), from [Kyle's review](https://github.com/block/buzz/pull/9346#discussion_r4539634396). A fenced code block that contains three backticks, such as a `"```"` string literal, closed at those backticks. The rest of its code then got the prose rules, so `"**literal**"` lost its asterisks in the notification. A fenced block now closes only at a fence at least as long as its opening fence that ends its line, as in CommonMark and the in-app preview. Along the way, fences the old regex handled loosely are now consistent with the app: a block fenced with four or more backticks keeps its code (and can hold a three-backtick fence), and a block's opening line is dropped as its language even when it contains a backtick. A one-line fenced span (`` ```just ci``` ``) and an unclosed fence running to the end of the text, on one line or several, are unchanged. **What gets better, what doesn't get worse:** code inside a fenced block now shows exactly as written. The change is one regex in the notification body function, and every existing shared case still passes. ### Validation - Added tests: six shared cases in `test-fixtures/notification-body.json`: backticks inside a block don't close it, a closing fence at the end of the last code line closes the block, a four-backtick block keeps its code, a four-backtick block can hold a three-backtick fence, an unclosed one-line fence runs to the end, and an opening line with a backtick is dropped. All 112 BuzzPushKit XCTest cases and the Swift Testing suite pass. - No visible change outside notification text for fenced code, so there are no new screenshots. Human testing is waived; the shared-case run covers the changed behavior. 🤖 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> |
||
|
|
432df8aa52 |
fix(media): run thumbnail regeneration tests in CI and cap rebuild pixels (#9198)
## Summary Two gaps in the thumbnail regeneration from [#9174](https://github.com/block/buzz/pull/9174): - **Its MinIO tests never ran in CI.** `api::media::tests::postgres_tests::external_infra_minio_tests` is excluded from the PostgreSQL lane, which skips `external_infra*` modules, and the backend integration job's only media MinIO step selects the admin tests. A sibling step now runs them against the same Postgres and MinIO. - **Rebuilding a thumbnail skipped the 25 MP pixel cap.** Upload validation rejects images over 25 megapixels before they reach the decoder. A thumbnail rebuilt from a stored original goes straight to `generate_image_metadata_sync`, so that cap didn't apply. The check is now a shared `check_image_pixels` that upload validation and thumbnail generation both run before decoding. On a rebuild, an oversized original gets the same 404 as any other regeneration failure. ### Related issue Follow-up to [#9174](https://github.com/block/buzz/pull/9174), from its review. ### Testing - `missing_thumbnail_is_not_regenerated_when_unsafe` now stores a valid 5001x5000 PNG as an image original without thumbnails and requests one through the media route. On `main` the relay rebuilds and serves it; with this change the request is a 404 and no thumbnail is written. - The new CI step runs both `external_infra_minio_tests` cases one at a time, since they share the fixture image's storage keys. 🤖 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> |
||
|
|
4a101752fb |
fix(mobile): never show emoji shortcodes in reaction pills (#9411)
## Context A reaction slot should never show an emoji shortcode as text. Today it does in two cases: - An agent's reaction is a kind 7 event with content `:shortcode:` and no NIP-30 `emoji` tag, so the pill printed `:approve:` in tiny text instead of the community emoji. - A custom emoji whose image fails to load fell back to the shortcode text. ## Behavior - A tagless `:shortcode:` reaction resolves against the community custom-emoji palette and renders that image. An `emoji` tag on the event still wins. - A shortcode with no palette entry, or whose image fails to load, shows a neutral placeholder glyph (the muted sparkles icon) instead of text. The shortcode survives only as the semantics label. - This applies to every reaction slot: the timeline pills, the combined reaction tray, the Flutter reaction detail sheet, and the native iOS reactions sheet (which is told which shortcodes have no image, so `NativeMessageGlyph` draws the placeholder). System-message rows reuse the same reaction row. - Unicode emoji are unchanged. The icon is the existing `BuzzIcons.sparkles`. Pareto note: the palette lookup is the shared `reactionEmojiUrl` helper, so there is no new resolver, and the pill only watches the palette when the reaction has no tag. ### Validation - `just mobile-check mobile-test`: format and analyze clean; full suite passes (4773 passed, 74 skipped). - New widget tests (journeys, in `reaction_row_test/shortcode_tests.dart`): a tagless known shortcode renders its image; an unknown shortcode and a failed image render the placeholder and never the text; unicode is unchanged; the native sheet payload marks image-less shortcodes. Reverting the fix makes both new journeys fail. - iOS: added a Swift unit test for the native glyph placeholder (`NativeMessageImageLoaderTests`); not run locally, the app run below exercises the same code. - Automated app run on a cloned iOS simulator with a fixture entry point (mocked media, synthetic palette): confirmed the pills before and after, and opened the reaction sheet from a long-press to confirm the placeholder renders there too. The clone was deleted afterwards. ## Before / after Captures use synthetic shortcodes and a synthetic palette. <img width="300" alt="reaction-pills-before" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-before.png"> <img width="300" alt="reaction-pills-after" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-after.png"> Reaction sheet after: <img width="300" alt="reaction-sheet-after" src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-sheet-after.png"> 🤖 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@buzz.block.builderlab.xyz> |
||
|
|
2f792c2f6b |
fix(mobile): isolate channel header scroll rebuilds (#9400)
## Summary Improve scrolling performance in the Messages channel list by avoiding unnecessary inventory rebuilds as the header moves. Keep scroll-driven header state in a notifier and retain the inventory while the header animates. Provider and parent updates still rebuild the inventory with current channel data. This complements the lazy-row optimization in #9401. In manual iPhone runs with 500 channels, adding lazy rows on top of header isolation reduced maximum observed Flutter UI work from **65.5 ms to 4.2 ms**. That measurement reflects the added lazy-row change; no standalone performance gain was established for header isolation. This is an independent production-only change. Test fixtures and diagnostic instrumentation are excluded; regression checks were run locally and are not included in this PR. ### Related issue Related: #5911 addresses scrolling behavior inside conversations. This change addresses header-triggered rebuilds in the Messages channel inventory. No matching open PR or issue was identified in the focused duplicate search. ### Testing Exercised Messages on a physical iPhone with 500 channels against an isolated local relay. Header collapse and channel updates worked. This change alone did not remove the large scroll stalls; lazy section rows are a separate change. The device run preceded the refresh onto current main. Local-only regression checks passed 10 tests on this independent branch. They verify stable inventory identity during header movement and visible channel updates. These checks are excluded from the commit. Remaining visible jumps are under investigation; this PR does not claim to resolve them. Generated with Codex Signed-off-by: Michael Fransen <mfransen@squareup.com> Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz> |
||
|
|
2636e986a0 |
fix(mobile): build channel section rows lazily (#9401)
## Summary Improve scrolling performance in the Messages channel list by building Solo and Team rows only as needed. Sections currently mount all their rows as one scroll item, which causes long UI stalls with large channel lists. In manual iPhone runs with 500 channels, maximum observed Flutter UI work fell from **65.5 ms to 4.2 ms** after adding lazy rows to a build with header isolation (#9400). The tester reported much smoother scrolling. This was an exploratory comparison with different gestures, not a controlled benchmark of this independent branch. Use stable channel keys and retain section controls, sorting, and collapse animations. Clip the sliver extent during collapse so the animation does not mount the entire section. The shared transition supports downward vertical lists. This is an independent production-only change. Test fixtures and diagnostic instrumentation are excluded; regression checks were run locally and are not included in this PR. ### Related issue Related: #5911 addresses scrolling behavior inside conversations. This change addresses eager row mounting in the Messages channel inventory. No matching open PR or issue was identified in the focused duplicate search. ### Testing On a physical iPhone with 500 channels against an isolated local relay, maximum Flutter UI work fell from 65.5 ms to 4.2 ms after adding lazy rows to a build that already isolated header rebuilds. The tester reported much smoother scrolling. Gestures differed between runs, so this is not a controlled benchmark or an isolated measurement of this branch on its own. The device run preceded the refresh onto current main. Local-only regression journeys passed 12 tests on this independent branch. They cover page laziness and updates, variable-height rows, large text, the last row, collapse reversal, reduced motion, and semantics. These checks are excluded from the commit. Smaller visible jumps remain under investigation; this PR does not claim to resolve them. Generated with Codex --------- Signed-off-by: Michael Fransen <mfransen@squareup.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
745d2a86e0 |
fix(cli): treat a retried sidebar write the relay already stored as success (#9202)
Follow-up to #9191. It addresses the review findings that came in as that PR merged. ## Summary **A retried write was reported as a conflict.** This was found by the Codex security review on #9191 (LOW). - `BuzzClient::submit_event` can resend the same signed event after an ambiguous attempt, for example a timeout after the relay stored it. - The relay answers that resend `accepted: true, message: "duplicate:"`. `publish` turned any `duplicate:` into exit 5 with "a newer update won; run the command again". - If you ran `buzz sidebar sections create` again, you got a second section. On a `duplicate:` reply, `publish` now re-reads the head from the writer (`consistency: strong`). It reports success only when the head is the event it signed. The check is safe because `replace_parameterized_event` returns `was_inserted = false` for both an exact-id duplicate and a stale (dominated) write. Only a stored event can be the head with our id, so a dominated write still exits 5. **Unknown keys are dropped on decode.** This was raised by joahg on #9191. The module docs now state the limitation and why it is safe: - Buzz Desktop (`block/buzz-app`, `validate_sidebar_payload`) refuses to sign a document with unknown keys, so a new field needs a new `meta.v`. - `decode` refuses an unknown `meta.v`. The commands then neither read nor overwrite that head. ## Not changed joahg also noted the lost-update window between the head read and the submit. That window is documented in `crates/buzz-cli/README.md` (from #9191). A kind:30078 replaceable event has no compare-and-set on the relay, so the CLI cannot detect the case. A test would only check the fake relay's replace rules. ## Testing - `cargo test -p buzz-cli`: 665 pass. `cargo clippy -p buzz-cli --all-targets -D warnings` and `cargo fmt --check` are clean. - New test: `a_retry_of_a_stored_write_is_success`. The fake relay stores the event and answers `duplicate:`. The test fails without the fix (`Conflict("a newer sidebar sections update won; run the command again")`). - `a_dominated_write_is_a_conflict` now seeds a newer head under the caller's own key. Before, every conflict test had an empty own head, so a looser check (`Some(_)`) still passed. The test now fails under that change. - An agent review ran. It found that gap in the dominated-write test, which is now fixed. Its remaining notes are optional. ### Manual test steps The retry path needs a lost relay response, so you cannot easily trigger it by hand. These steps check that normal writes did not change: 1. Run `cargo build -p buzz-cli`. Then set `BUZZ_RELAY_URL` and either pair with Buzz Desktop (`buzz auth desktop pair`) or set `BUZZ_PRIVATE_KEY`. 2. Run `buzz sidebar sections list` and save the output. 3. Run `buzz sidebar sections create --name "Retry test"`. Expected: `"accepted": true`, a non-null `event_id`, and a `section_id`. 4. Run `buzz sidebar sections delete --section "Retry test"`. Then run `list` again. Expected: the same output as step 2. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Blake McAnally <bmcanally@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fc8c802253 |
feat(workflow): add owner-only agent mentions for workflow messages (#9227)
Adds owner-only agent mentions, a relay setting,
`BUZZ_WORKFLOW_AGENT_MENTIONS`. Its values are `any`, the default and
today's behaviour, and `owner`. Any other value fails startup. With
`owner`, a workflow message can direct an agent only if the run's cause
and definition allow it. The setting is relay-wide.
**Recording what caused each run.** `workflow_runs` gets `cause_source`
and `cause_pubkey` columns. Migration `0087` adds them with named checks
marked `NOT VALID`, so it does not scan existing rows under its lock;
`0088` validates the checks separately. `create_workflow_run` now takes
a required `RunCause`, so every place that creates a run has to set one:
- **Event trigger:** the cause is the signer of the message, reaction or
diff. For a reaction, that is the reactor, not the author of the message
being reacted to.
- **Cron and interval schedules:** the cause is the timer.
- **Webhook:** the caller is unknown.
- **Manual trigger:** the cause is the invoking pubkey.
Rows written before this change have no cause. A stored source without
the pubkey it requires, or with one it forbids, is read as no cause.
**Deciding eligibility.** A run's messages may direct agents only when
all of these hold:
- the cause is a schedule, or an event or manual trigger whose pubkey is
the workflow owner's;
- every step of the definition that is running is `send_message` or
`delay`. This is an allowlist, so any other action, including one added
later, makes the definition ineligible, even when an `if:` skips that
step;
- the run did not resume after an approval.
Webhook runs and runs with no recorded cause are never eligible. The
executor decides this from `WorkflowConfig::agent_mentions`, and passes
`ActionSink::send_message` the owner's stored step text with a
`may_direct_agents` flag. The sink adds `buzz:workflow-mention` tags
only when the flag is set, and returns `SentMessage { event_id,
agent_mentions_withheld }`, which is true only when it left out a tag
that a member named in both the stored and rendered text would otherwise
have received. Webhook and manual runs start through `execute_run`, so
`execute_from_step` is used only to resume after an approval.
**The gate.** With `owner`, the relay adds `buzz:workflow-mention` only
to messages from eligible runs. Messages from other runs still post with
their plain `p` tags and still notify people. With `any`, mention tags
are added exactly as before.
**Making it visible.** When a `send_message` step leaves the tags out:
- its output in the run trace gets `"agent_mentions": {"withheld": true,
"reasons": [...]}`, listing each reason that applies: `cause` (not
started by the owner or a timer), `steps` (a step other than
`send_message` or `delay`), `resumed` (resumed after an approval). The
key is absent when nothing was withheld. The trace is what the app's run
history shows;
- the relay logs it at info level under the
`buzz_relay::workflow_agent_mentions` target (visible under the default
filter) with the community, workflow, run, step and reasons;
- the counter `buzz_workflow_agent_mentions_withheld_total` increases
once per reason, labelled `reason`.
**Discovery.** The NIP-11 document always carries
`"workflow_agent_mentions": "any" | "owner"`, so a missing field means
an older relay.
`NOSTR.md` now specifies `buzz:workflow-mention` for clients: what the
tag means, the checks an agent makes before acting, that a relay may
leave it out under a policy, and the NIP-11 `workflow_agent_mentions`
field. The relay policy itself is in the new operator guide,
`docs/operator-workflow-agent-mentions.md`: the setting and its values,
what counts as a run's cause for each trigger, which steps are allowed,
the trace key, the log and metric, the NIP-11 field, when to use
`owner`, rollout across pods, and what it doesn't cover. The
`TESTING.md` and `.env.example` entries link to it.
---------
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v1.1.0-rc.2
|
||
|
|
cb69ad9e4f |
Adapt iPhone Duo layouts, rail navigation, and native sheets (#8568)
Duo keeps conversations and Settings details open across closed, book and flat postures. Book mode splits at the fold; flattening restores the sidebar. Native rail controls, full-width navigation backdrops and sheet-local safe areas keep content aligned. Ordinary iPhones remain single-pane in portrait and landscape; tablet and Duo split layouts remain enabled. Includes the recovered preview's matching pane widths, Buzz empty-pane mark, stable Settings chevrons, Bestie pane routing and Duo-only upward New menu attachment. Current main's Activity redesign, Search content fade and unread-navigation button are retained. Menu selection checks sit beside their labels. Shared mobile changes remain in scope: the native iOS status editor, Appearance preview layout, avatar crop preservation, onboarding footer layout, profile/invite sheet geometry and nested conversation navigation. This PR is not exclusively Duo-gated. Nested navigator observers preserve recording cancellation and navigation callbacks; crop resizing preserves the selected image region. iOS 27.1 APIs remain compile-time and runtime gated. Validation on the reconciled source: Flutter analysis, Activity/Home journeys, the full mobile test suite, file-size checks and Swift package tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro simulator before reconciliation. Fresh hosted CI and physical-device confirmation of the reconciled build are separate and remain pending. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
5f81803cb1 |
End last-human agent Huddles and preserve mobile transcript access (#9375)
## Summary Leaving an agent DM Huddle previously skipped client lifecycle cleanup, allowing Join to remain visible while the relay waited for the agent. Apply the existing last-human rule to DMs: release audio first, then publish the ending and archive when the human count is at most one. Preserve the existing additional-device and same-Huddle rejoin guards. Retain DM membership when other humans remain so hanging up does not revoke transcript access. Offer **View** only when cached backing-room membership confirms transcript access, opening the original Huddle transcript and reply threads without joining audio. Non-members retain disabled Ended. Preserve Open for an active local session even when an ended event arrives. For admitted or just-left Huddles, switching/removing a community retires pending remote finalization and waits for local audio teardown. Generation guards prevent retired count/end results from launching follow-up writes or interfering with a later call. Existing in-flight Huddle-start rollback behavior is unchanged. Client only; remote relay finalization is separate in #9376. Human counts use backing-channel membership; known duplicate owner admissions prevent client cleanup. Concurrent device arrivals are not made atomic by this client-only change, and human-to-human DMs with retained memberships still rely on relay finalization. ### Related issue Split from the combined Huddle repair at the author's request. Searched open Huddle issues/PRs; no exact duplicate found for this mobile behavior. ### Testing Follow-up fixes are in `7f28dc486`; merged current main in `7a592f19d`. Required push checks pass on the merged head. - Mobile formatting, analysis, and full suite passed (4,705 tests, 71 skipped; supplementary suites: 4 and 27). - DM hangup journey covers sole-human ending, preservation of another owner device, and preservation of membership when another human remains. Existing rejoin/lifecycle regressions remain in the full suite. - View journey reads the original transcript and opens a server-only reply-thread summary without joining audio. - Mutation checks: restoring the DM cleanup exemption fails the last-human journey; removing thread summaries fails View navigation. Restored production code passes. - Independent review completed; follow-up review of the three blocking fixes found no remaining blockers. - Follow-up regressions cover live-call Open after an ended event, reactive history membership revocation, stalled count/end requests during community transition, and background teardown of a later call while retired cleanup is unresolved. Phone/human validation remains pending. Start an agent DM Huddle and leave from the last human device: its card should stop offering Join. Tap View to read the transcript and replies. Repeat with another device still in the Huddle and verify it stays open. Not deployed. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
aa9b44ef36 |
buzz-db: pin explicit FK-child cleanup and same-workflow fire links (#8317)
> Builds on #8219 (merged); rebased onto `main` at `93ffc37cb` (includes the rustls bump, #8319). ## What This closes the identified debt from #8212. Every remaining `ON DELETE CASCADE` / `SET NULL` foreign key was audited against every supported way its parent row gets removed, with FK actions retired in the per-test schema (`test_support::retire_foreign_key_delete_actions`). One production change was needed. The rest is already explicit and is now covered by tests. **Production change:** `attach_scheduled_workflow_run` (`store/workflow.rs`) now links a scheduled fire only to a run of the **same** workflow (`EXISTS` on `(community_id, workflow_id, id)` in the existing admitted `UPDATE`). Before this, a fire for workflow B could reference a run of A. That FK is already `NO ACTION`, so the link would block A's explicit run delete. The only production caller (`buzz-workflow` cron tick) attaches the run it just created for the same workflow, so live behavior is unchanged. A mismatched attach now returns `false` like any other non-match, and the caller already treats that as best-effort. ## FK audit (all 12 delete actions) | Child FK | Action | Parent removal path → handling | |---|---|---| | `scheduled_workflow_fires → workflows` | CASCADE | Workflow delete removes fires explicitly (#8212) | | `workflow_runs → workflows` | CASCADE | Explicit (#8212) | | `workflow_approvals → workflows` | CASCADE | Explicit (#8212) | | `workflow_approvals → workflow_runs` | CASCADE | Explicit, including cross-workflow approvals (#8212) | | `join_policy_acceptances → relay_members` | CASCADE | Explicit (#8212) | | `channel_members → channels` | CASCADE | Single-channel removal is a soft delete. The community purge deletes members before channels. **Pinned here.** | | `personal_read_frontiers → personal_read_accounts` | CASCADE | No single-account hard delete exists. The purge deletes frontiers before accounts. **Pinned here.** | | `personal_read_frontiers → channels` | CASCADE | Same as above. **Pinned here.** | | `users.agent_owner_pubkey → users` | SET NULL | The only production hard delete of users is the purge deleting all of a community's users in one statement. **Pinned here** (owner + owned agent). No single-user hard delete exists. | | `product_feedback → communities` | SET NULL | Communities are tombstoned, never hard-deleted. The purge already nulls feedback `community_id` explicitly. **Pinned here.** | | `community_deletion_manifest_keys → community_deletion_requests` | CASCADE | Requests are retained. `mark_logically_verified` deletes chunks explicitly, and the chunk guard trigger is unchanged. **The existing lifecycle test now runs with FK actions retired.** | | `community_deletion_executor_heartbeats → community_deletion_requests` | SET NULL | Requests are retained, so this action is never reached. **Pinned here:** the heartbeat stays linked through the purge. | Audit method: searched `crates/**/*.rs` for `DELETE FROM` against each parent table. Every direct `DELETE FROM communities` / `users` hit is in `#[cfg(test)]` fixtures. Production removes rows only through the scoped purge loop in `store/deletion.rs` and the channel soft delete in `store/channel.rs`. **Not added:** per-parent delete APIs. They would duplicate the community purge for parents that are never hard-deleted. I also left out a source-text "no hard delete" policy test, because it would pass without any production fix. If a single-user, channel, read-account, request or community hard delete is ever added, it has to clear `agent_owner_pubkey` (only that column) and delete these children explicitly first. ## Tests - `attach_rejects_run_from_another_workflow`: a claim for B can't be linked to A's run, and the fire stays unlinked. - `purge_removes_fk_children_explicitly_without_fk_actions`: on the retired-FK schema, `channel_members` and `personal_read_frontiers` children are seeded through an admitted transaction, and then the production quiesce → fence → purge runs. Every child must be deleted explicitly, both users must be removed, feedback must survive with `community_id = NULL`, and the retained request's heartbeat (selected by `executor_id`) must stay linked. The quiesce barrier against open admitted writers is already pinned by `begin_quiescing_waits_for_open_admitted_writer_note_update`; this test does not re-claim it. - `manifest_key_chunks_bind_freeze_execution_and_cleanup` now retires FK actions first. Each test was checked to fail when its production behavior is removed: dropping the `EXISTS` guard, dropping `personal_read_frontiers` from the purge order (`23503`), dropping feedback provenance clearing, and dropping the explicit manifest chunk delete. All four failed as expected. ## What got simpler The production change is one predicate. No new concepts or APIs. The FK audit is now settled, so the follow-up migration can drop all 12 actions at once. No new delete code was needed. **Identified debt (pre-existing, outside this diff):** the cron tick in `buzz-workflow/src/lib.rs` logs only `Err` from `attach_scheduled_workflow_run`; an `Ok(false)` (no matching unlinked claim) leaves the fire unlinked without a trace. **Identified debt (unrelated, pre-existing):** `writer_pool_rejects_non_read_committed_database_default` hangs with no active query on clean `177551dda` as well (bounded after ~270 s on both checkouts). ## Verification (Blox, at the exact tree of this commit) | Gate | Result | |---|---| | `cargo fmt --all -- --check` | pass | | `cargo clippy --workspace --all-targets -- -D warnings` | pass | | `cargo nextest run -p buzz-db --lib` | 166/166 | | `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion / push-gateway / relay / search / workflow | all rc 0 (4, 7, 11, 11, 402, 20, 2) | | `scripts/postgres-test-run.sh -p buzz-db` | 510 pass, 2 fail: the pre-existing hang above, plus one 150 ms `PoolTimedOut` in `routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold` (pool setup under full-lane load; it passed on the clean-baseline lane and passes in isolation at this tree, 1/1 in 1.7 s) | The new tests are selected by the `postgres-ci` nextest filter `(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)`. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> 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> |
||
|
|
0c58b33e64 | chore(deps): bump hickory to 0.26.3 for RUSTSEC-2026-0506..0520 (#9394) | ||
|
|
193fda22f5 |
fix(db): seek replaceable heads by channel (#9147)
## Summary `replace_addressable_event_under_lock` read and superseded a replaceable coordinate's head with `channel_id IS NOT DISTINCT FROM $4`. A btree can't use that as an index condition, so the read filtered every live row of the `(kind, pubkey)` across all channels. The relay signs every channel's state events (39000–39002) with one key, so each channel-state write walked the whole community's relay state for that kind. Both `Db::replace_addressable_event` and the relay's locked group-state snapshots go through this function. The predicate is now spelled per case, as `channel_id = $4` or `channel_id IS NULL`, and `$4` is bound only for the first. The head read becomes an index seek to one channel instead of a filtered walk. It reads the same rows as before. The `WHERE` scope and the head read are shared as macros (`replacement_scope!`, `replacement_head_sql!`), so the plan test checks the same predicate production runs. The supersede statement is spelled inside the function itself, which takes `&mut AdmittedTx`, as the guarded-write source scan requires. `superseded_with_mentions_sql!` now takes an `expr`, so it accepts a `concat!`. ## Tests - `replacement_channel_predicate_is_an_index_condition` seeds one relay head per channel plus one channel-less head and refreshes the statistics. Then, for both cases, it checks that the head read and the supersede each have `channel_id` in an `Index Cond`. With `IS NOT DISTINCT FROM`, the planner walks the newest-first `(pubkey, kind)` index and applies the channel as a `Filter`. On an empty table it does that even with the per-case spelling, which is why the test needs the fixture. - `addressable_replacement_supersedes_only_its_own_channel_scope`: a channel write supersedes only that channel's head, and a channel-less write is ordered against the global head only. - **Mutants**, each killed by its test: - the scope restored to `IS NOT DISTINCT FROM`; - the global scope ignores the channel; - the in-channel supersede ignores the channel. ## What got simpler - One predicate per case instead of a NULL-safe comparison the index can't use. - The head read and supersede share one scope definition. 🤖 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>mobile-v1.1.0-rc.1 |
||
|
|
6878520604 |
buzz-db: check events created_at floor in app writers (phase 1) (#8325)
## What This is phase 1 of 2 for retiring the `events_created_at_floor` deferred constraint trigger and its `events_created_at_floor_guard()` function (migration 0021). The invariant now holds in the app writers. **The trigger, function and `buzz.created_at_floor` GUC arming all stay** as the backstop while pods run mixed versions. A later PR drops them. One helper, `replica_fence::guard_event_created_at_floor(&mut AdmittedTx, created_at, channel_id)`, runs on the admitted write connection. If `created_at < clock_timestamp() - CREATED_AT_FLOOR_SECS`, it rejects the write with the typed, non-retryable `DbError::CreatedAtFloorRejected`. It uses the same 960 s constant as the GUC and the fence wall. Channel-less rows skip it and cost no query. **Why checking at insert time is enough:** the fence wall is `min(oldest_xact_start, S) - floor - 5s`. A writer transaction that is open during the probe's activity scan is bounded by its own `xact_start`. A transaction that starts after the scan samples a clock later than `S`. So any `clock_timestamp()` sample taken inside the writing transaction is as strong as the trigger's commit-time sample. This needs no commit hook, no candidate-row tracking and no ordering against the TTL refresh, and `AdmittedTx::commit` is unchanged. | Site | Check placement | |---|---| | `store/event.rs` `insert_event_on` | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, before follow-ups | | `store/event.rs` `insert_event_with_thread_metadata_tx` | after the INSERT, only when a row was inserted, before follow-ups and thread metadata | | `store/replaceable.rs` parameterized replacement | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, inside the supersede savepoint (a rejection rolls back to it and restores the old head) | | `store/replaceable.rs` addressable replacement | after the `ON CONFLICT DO NOTHING` INSERT, only when a row was inserted, before follow-ups (the caller's rollback restores the old head) | | `store/channel_members.rs` `LockedMemberSnapshot::replace_member_event` | before the old head is superseded | **Writer inventory:** there are 7 production `INSERT INTO events` functions. The other two are structurally `channel_id = NULL`: `push.rs` `accept_lease_event` (kind 30350) and `relay_members.rs` `publish_nip43_membership_locked` (kind 13534). Both write a literal `NULL` for `channel_id` in their INSERT SQL, and the policy exempts them only while that NULL sits at the `channel_id` position. No production UPDATE writes `events.created_at` or `events.channel_id`, and the events `ON CONFLICT` clauses are `DO NOTHING`. **Client-visible outcome is unchanged.** The trigger's SQLSTATE 23514 surfaced as a generic DB error, which the relay maps to `IngestError::Internal` (WS `OK false` / HTTP 500). The new variant follows the same path. Relay mappings are untouched. **`scripts/seed-admin-dashboard.sh`** writes a channel event with `created_at = 1790000000` through raw psql, and it accepted any `PGHOST`. It now refuses non-local hosts (`localhost`, `127.0.0.1`, `::1` and Unix socket paths are still allowed), a non-empty `PGHOSTADDR` or `PGSERVICE`, a multi-host `PGHOST`, and any `PGDATABASE` that is not a plain name (psql treats URI and `key=value` `-d` values as connection strings that override `-h`). Both psql paths pass `-X`, so a psqlrc can't `\connect` elsewhere after the guard. ## Tests There are eleven new PG tests in `store::event_floor_postgres_tests`. Each runs in its own per-test database with `ALTER TABLE events DISABLE TRIGGER events_created_at_floor`, and a `pg_partition_tree` check asserts that every partition clone is disabled, so the trigger can't hide a missing app check. - `insert_event_floor_rejects_old_and_accepts_fresh`, `thread_metadata_event_floor_rejects_old_and_accepts_fresh`, `parameterized_event_floor_rejects_old_and_accepts_fresh`, `addressable_event_floor_rejects_old_and_accepts_fresh`, `member_snapshot_event_floor_rejects_old_and_accepts_fresh`: each rejects a too-old event with the typed error, confirms no row was written, then accepts a fresh event. - `insert_event_floor_accepts_expired_duplicate`, `thread_metadata_event_floor_accepts_expired_duplicate`: replaying an already-stored event whose `created_at` is now below the floor returns `Duplicate` (inserted = false), leaves one row and runs no thread follow-ups. - `parameterized_replay_of_soft_deleted_aged_event_is_a_duplicate`, `addressable_replay_of_soft_deleted_aged_event_is_a_duplicate`: replaying a replacement whose stored row is soft-deleted and has aged below the floor returns a duplicate, not a floor rejection, as the trigger did (it never fired on `ON CONFLICT`). - `cluster_global_event_floor_held_transaction_cannot_write_below_probe_wall`: runs a real heartbeat probe while a writer transaction is open. It asserts that the wall is bounded by that writer's `xact_start`, that a below-wall event is rejected, and that a near-floor event sampled after the probe lands above the wall. - `event_floor_samples_statement_clock_after_transaction_start`: waits on the database until a boundary timestamp that was valid at BEGIN expires (normally ≤1.1 s), so `now()` would pass where `clock_timestamp()` fails. Source policy (`observability_source.rs`): every channel-bearing production events INSERT in any crate's `src` must call the helper, no production UPDATE may assign `created_at` or `channel_id` (including `ONLY`, schema-qualified, aliased and row-constructor forms; quoted identifiers are normalized), the helper must use `clock_timestamp()` and the shared constant, and the seed script must refuse a remote host. Negative fixtures cover each rule. Each check was confirmed to fail when its fix is removed. All of these mutants failed on assertions: - removing the helper from all five writers, which failed all seven PG tests - switching `clock_timestamp()` to `now()`, which failed both the clock PG test and the clock policy - removing one call - adding a `created_at` UPDATE - disabling the policy self-test - removing the seed guard - (review round 2) restoring the pre-INSERT check placement, which failed both duplicate-replay tests; the round-1 UPDATE matcher, quoted-identifier normalization, buzz-db-only scan, NIP-43 bind exemption, process-wide canvas base and each seed override were each restored and failed their tests - (review round 3) restoring the round-2 seed script, which failed the extended seed test on its URI and key/value `PGDATABASE` cases; restoring the script without `-X`, which failed the control's `-X` assertion **Fixture changes:** about 40 existing buzz-db PG tests seeded historical rows (fixed old timestamps) through the serving writers. The trigger never fired on them because the test pool is unarmed. They now seed through a `#[cfg(test)]` raw-SQL helper, `test_support::insert_historical_event` (and `insert_historical_event_with_thread_metadata` for thread fixtures), which keeps admission, follow-ups and mentions. The canvas CAS fixtures keep their logical seconds and ties but each test passes its own fresh base. The existing trigger backstop tests still exercise raw armed INSERT/UPDATE plus COMMIT returning 23514. The new PG tests are selected by the `postgres-ci` filter `(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)`. The `cluster_global_` test also joins the existing serial group. ## Behavior notes - Prepared transactions (2PC) are covered only by the retained trigger, which runs at PREPARE. The insert-time app guard runs earlier, and no app writer issues `PREPARE TRANSACTION`. The comment in `replica_fence.rs` now says so, so the trigger drop has to keep this case covered. - One extra round trip for each newly inserted channel event. Duplicate replays add none. - Ordinary and thread event writes check the floor only for newly inserted rows, before follow-ups, mentions or thread metadata. Replaying an already-stored id stays `Duplicate` even when its timestamp has aged below the floor, as with the trigger. (Internal writers such as the admin outbox worker do replay relay-signed events with their original `created_at`.) A too-old new row returns the non-retryable `CreatedAtFloorRejected`, and the owning transaction rolls back; every current caller propagates the error and drops the transaction. The transaction-composition seam documents that contract. - Replacement writers check after their INSERT, only when it inserted a row, so a replay of any stored id (live or soft-deleted) stays a duplicate. The locked roster writer checks before the old head is superseded. - The trigger is stricter for a row that ages below the floor between the app check and COMMIT. The proof above doesn't need that strictness, and phase 2 gives it up. ## What got simpler The NIP-43 insert drops a bind for a literal `NULL`, the floor and follow-up policies share one workspace walk, and the canvas fixtures lose their process-wide `OnceLock`. No commit-time machinery was added: no candidate tracking, commit hook, cleanup protocol or new lock. Test-only historical writes now go through one helper instead of being scattered through the serving APIs. Nothing could be deleted in this phase because mixed-version pods still rely on the trigger. **Phase 2** drops the trigger and function, the GUC arming, `verify_floor_guard_catalog` / `verify_floor_guard_behavior` and the schema reconciliation entries. Its deploy gate: the whole fleet on this binary, no rollback expected, and the replica breaker held closed through the drop. **Identified debt (pre-existing, outside this diff):** three `buzz-relay` `handlers::group_state_postgres_tests::*` tests fail with `ensure community: Sqlx(PoolTimedOut)` on the Blox host. They fail the same way on a clean checkout of the base (`state.rs` test pool, 100 ms acquire timeout). `writer_pool_rejects_non_read_committed_database_default` and `writer_pool_metrics_stop_after_session_timeout_setup_failure` hang on that host at base too. ## Verification Head `b046df76d` (after the placement fix and main's newer test fixtures): on Blox, fmt, clippy and buzz-db unit (241/241) pass, and a filtered buzz-db PG lane over the floor tests and every converted fixture passes 88/88. CI is running at this head. Earlier full gate, at tree `67c4fe5e1` + this diff, before the placement fix: | Gate | Result | |---|---| | `cargo fmt --all -- --check` | pass | | `cargo clippy --workspace --all-targets -- -D warnings` | pass | | `cargo nextest run -p buzz-db --lib` | 167/167 | | `cargo nextest run -p buzz-db --test observability_source` | 22/22 | | `scripts/test-postgres-test-discovery.sh` | pass | | `scripts/postgres-test-run.sh -p buzz-db` (excluding the two host hangs) | 517/517 | | `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion / push-gateway / search / workflow | all rc 0 (4, 7, 11, 11, 20, 2) | | `scripts/postgres-test-run.sh -p buzz-relay` | 402 pass, 3 fail (the pre-existing `PoolTimedOut` failures above) | Local gates used a reverted `rustls` lock bump, because the build mirror lacks 0.23.46. The committed `Cargo.lock` is unchanged, and CI uses the real lock. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> 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> |
||
|
|
473d592602 |
fix(mobile): keep a thread tail behind the keyboard unread (#9295)
Follow-up to [#9243 read tall mentions](https://github.com/block/buzz/pull/9243). **Problem.** Since #9243, the newest reply in a thread is read once its end shows at the live bottom, even when it is taller than the screen. The thread view decides "at the tail" from scroll geometry that ignores the keyboard. On Android, with the keyboard open and tail-follow detached, the last reply can sit at the scroll end but entirely behind the keyboard, and it gets marked read. The mark stays after the keyboard closes, so a mention there can be cleared without ever being seen. **Fix.** The thread's live bottom now also requires the tail row's end to be inside the reading area, above the keyboard and composer. This matches how the channel timeline already checks its bottom row. ### Validation - Added test: a thread whose last reply sits behind the Android keyboard leaves it unread, including after the keyboard closes. It fails without the guard. - Full mobile suite and `flutter analyze` 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> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c0e30110dd |
fix(mobile): restore header cues and per-channel identity check for #9369 (#9370)
Follow-up to #9369 (conversation header avatars). Fixes two regressions found in its review. The intended behavior of #9369 stays the same. 1. **Message rows no longer each scan the roster.** `showMessageAuthorName` ran at the top of every message and thread row, in every channel. Each run watched a whole `Channel` object (which has no value equality) and built sets from the full roster, plus two more sets for the stale-DM check in DMs. A new `confirmedConversationPairProvider` works out the two-person pair once per channel, including the rule that a DM keeps attribution while its metadata disagrees with the roster. It returns a record that compares by value, so rows rebuild only when the pair itself changes. It reads `watchLatestChannelMembers`, so a revisit uses the cached roster and rows no longer re-wrap 48 dp wider once the roster fetch returns. The visible rule is the same as before. 2. **Android private channels show the lock again.** Every Android conversation header now uses the compact title pill, which had no lock. The pill now shows the existing lock icon (and its "Private channel" label) before the name. ### Validation - Added tests, all extending existing journeys: - The author-visibility journey checks that replacing the channel object (as live activity does) doesn't notify rows when the pair is unchanged. Its existing `stale-dm` case covers the metadata rule now inside the provider. - The private-lock header test expects the lock again. - Each new or restored Dart assertion fails with its fix reverted, and so does the `stale-dm` case with the provider's metadata rule removed. - Full mobile suite (`just mobile-test`) and `flutter analyze` pass. - Not run: an automated full-app session on a simulator or device. 🤖 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> |
||
|
|
e715fbb362 |
fix(relay): treat empty filter sets as match-nothing in SQL (#9342)
Fix for a COUNT regression introduced by #9339 (now on main), found in its review. With #9339, a filter with a nonempty `#a` takes the exact SQL COUNT path. But `filter_to_query_params` dropped an empty `authors`, `ids` or `#e` list and never represented an empty `#p`, `#d` or unpushed tag set. So `{"kinds":[7], "#a":[A], "authors":[]}` counted every reaction to `A`, while REQ (which post-filters) correctly returned nothing. Before #9339 that COUNT returned 0. The same gap already existed for filters without `#a`. Any empty `authors`, `ids` or tag set now maps to the existing `kinds: []` match-nothing sentinel, which every read (REQ, COUNT, `/query`, `/count`) answers without a query. An empty string value such as `"#d": [""]` is still a real constraint. Why nothing gets worse: only filters that provably match nothing change, and they now skip the database instead of reading or counting. ### Validation Added tests: - Unit table over empty `authors`, `ids`, `#e`, `#p`, `#d`, `#h`, `#k` and `#t`, each with and without `#a`, plus `"#d": [""]`. It fails with the sentinel disabled. - Postgres table: WebSocket and HTTP COUNT are 0, and REQ and `/query` are empty, for each empty set beside a matching `#a` and for `{kinds:[7], authors:[]}`. The first commit contains only the tests. Existing workspace unit tests pass (pre-push). The Postgres lanes run separately on an isolated cluster. 🤖 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> |
||
|
|
0e6037c74d |
buzz-cli: add --before and --before-id to messages search (#9382)
## Summary `buzz messages search` returns at most the newest 100 matches after `--since` and had no end time. So an author-only search could never reach older messages. This adds two flags to page back in time: - `--before <unix ts>` — sent to the relay as the filter's `until` (inclusive). - `--before-id <event id>` — requires `--before`. Sends the relay's existing `before_id` cursor, so events from the same second are neither repeated nor skipped. This is the same `(until, before_id)` cursor that `social notes --before/--before-id` already uses. Without the new flags, the filter and output are unchanged. ### How to page 1. Run `buzz messages search --author <key> --since <ts> --before <now> --limit 100`. 2. For the next page, pass the last result's `created_at` as `--before` and its `id` as `--before-id`. 3. Stop when a page comes back empty. Results are newest first, and events from the same second are ordered by ascending id. That matches the relay's cursor order. ### `--query` rule `--before-id` with `--query` is a usage error (exit 1). Full-text results are ranked by relevance, not time, and the relay's search path does not apply `before_id`. `--before` on its own still works with `--query` as a time bound. No relay change was needed: the relay's non-search query path already honors `until` and `before_id`. ## Tests - New unit test for the filter the command builds. It covers: no flags (unchanged filter), `--before` mapping to `until`, `--before-id` lowercased, `--before` with `--query`, and the usage errors (`--before-id` with `--query`, without `--before`, malformed id). Removing the `before_id` line makes it fail. - New clap test: `--before`/`--before-id` parse, and `--before-id` alone is rejected. ## Checks run - `cargo fmt --all -- --check` - `cargo clippy -p buzz-cli --all-targets -- -D warnings` - `cargo test -p buzz-cli` (686 passed) - Ran the built CLI against a live relay. Paged an author-only search back across several full pages: no overlaps at page boundaries, and the union matched an independent time-sliced query exactly (no gaps). A manual check confirmed the relay applies `before_id` at a shared timestamp. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tim Mellor <117199716+tmellor-block@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a09b84d23d |
feat(relay): agents cannot write in DMs except with their owner (#9371)
## Summary Agents stop writing in direct messages, except a DM with only their owner. This is the relay-side backstop for block/buzz-app#3261, which takes away the app agents' DM tools. An agent holding its own key could still sign DM events directly, and only the relay can stop that. A key counts as an agent when it has a stored owner (`users.agent_owner_pubkey`, written when the agent authenticates with a NIP-OA owner attestation). For those keys: - **Writes into a DM channel** are refused with `restricted: agents cannot post in direct messages; start a Solo chat that includes your owner instead`. The check sits in `check_channel_membership` for `ChannelWrite::Post`, so it covers stream messages, reactions, deletions, typing and other channel ephemerals, artifact moves and edit validation. Non-DM channels skip it at no extra cost. - **Workflow `send_message`** (which posts as the workflow owner and checks `may_post` directly) applies the same rule before persisting, so an agent can't route a DM post through a workflow. - **Put-user (9000) and edit-metadata (9002)** skip the generic gate, so they get their own check in a DM: an agent never adds DM members with 9000 (an addition to an owner-only DM always widens it), and edits topic or purpose only in its owner-only DM (the edit posts a system message with the text). - **Huddle admission** checks `may_post` directly, so it applies the same rule to the huddle channel and its lifecycle parent. - **DM open (41010) and DM add-member (41011)** are refused unless the resulting participant set is exactly the agent and its owner. - **Exemption:** a DM whose live members are exactly the agent and its owner stays open (for example, a personal assistant's home chat). - Lookup failures fail closed. Confirmed agent owners are cached per community (`agent_owner_cache`, 5 min TTL). The link is first-write-wins and never changes once set. A missing link is never cached, because another relay process may write it later. `link_agent_owner` primes the cache. Humans are unaffected: they can still open DMs that include an agent and post in them. The agent answers elsewhere. Not covered: - A key that never presented an owner attestation has no stored owner and looks human. - The huddle join commit re-check (4e) doesn't repeat the DM rule; the pre-join admission check enforces it. - Agents also can't delete their own earlier messages in a non-owner DM via kind 5, since deletions pass the same `Post` gate. ### Related issue Companion to block/buzz-app#3261. AGNTOPS-780. ### Testing New Postgres ingest tests (`handlers::ingest::postgres_tests::agent_*`), run locally: - An agent is refused in a 1:1 DM with someone else and in a group DM with its owner; the humans in both still post. - An agent posts in a DM with only its owner. - An agent can open a DM only with its owner; a human can still open a DM with an agent. - An agent can't add members to its owner DM, through 41011 or 9000. - An agent can't set the topic in a DM with a stranger. - An agent is refused admission to a huddle in a DM with a stranger and admitted in its owner-only DM; a human still joins. - An agent's workflow `send_message` is refused in a DM with a stranger and allowed in its owner-only DM. - An owner linked after a lookup miss is seen on the next lookup (no negative caching). `cargo clippy -p buzz-relay --all-targets -D warnings` is clean. The other ingest and command-executor tests pass locally (214), except two that fail only because of my local database: one needs the desired-state FTS schema, and the other fails creating its restricted role in setup. Neither reaches DM code. I pushed without the local pre-push `just test-unit` lane because a `buzz-cli` unit test hung there for about 30 minutes; CI runs that lane. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Joah Gerstenberg <joah@squareup.com> Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1b190d21fc |
fix(relay): bound each cross-node delivery attempt (#9305)
**Context:** #9302 (merged) stopped the relay from closing connections when cross-node fan-out falls behind. It also removed the 30 s delivery deadline. This PR is the fix from the review of #9302. **Problem:** Since #9302, a fan-out worker waits for the access-gate database read with no time limit. If a database connection stops answering in the middle of that read, the worker never continues. Its queue fills, and one sixteenth of the node's channels get no live events from other nodes until the relay restarts. Before #9302 the deadline cut that delivery off after 30 s. **Fix:** Each delivery attempt has a 30 s limit (`MAX_DELIVERY_ATTEMPT`), measured from when the worker starts it. When the limit passes, the relay counts the event in `buzz_multinode_fanout_dropped_total{reason="stale"}`, resyncs the artifact syncs the event matched, and continues with the next event. It closes no connection. An event that only waits in the queue is still delivered late, as #9302 intends. The queue-full drop and the new stale drop share one helper. ### Validation - Added test: `hung_delivery_is_dropped_at_the_bound_and_the_worker_moves_on` drives the production worker with a database that accepts the connection and never answers. It checks that the delivery stops at the limit, is counted, resyncs the matched sync, closes nothing, and that the next event on the same worker is delivered. With the limit removed it fails (the worker waits out the pool's one-hour acquire timeout instead). - The other `handlers::multinode_fanout` tests pass. The full local `buzz-relay` suite was not run; CI on this head covers it. 🤖 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> |
||
|
|
27f0fe7527 |
feat(mobile): add iOS Home Screen shortcuts for New chat, New DM and the Bestie (#9360)
Long-pressing the Buzz icon on iOS now offers three quick actions: - **New chat** opens a new solo chat (the same draft as New › New chat). - **New DM** opens the New DM picker. - **Message <Bestie>** opens the DM with the current community's Bestie, using the agent's display name (for example "Message astra"). It's hidden when the community has no Bestie, and it updates when the Bestie, its name or the community changes. | Before | After | | --- | --- | | <img alt="before" src="https://github.com/user-attachments/assets/393c3fb6-fe64-4505-a7a4-12714d8bf1df" width="300"> | <img alt="after" src="https://github.com/user-attachments/assets/1c57df92-6fed-4fee-9ce8-e9ea0ec70294" width="300"> | ### How it works Home publishes the items through a small native bridge (`buzz/home_screen_shortcuts`), which sets `UIApplication.shortcutItems`. A tap goes through the scene delegate. The bridge keeps the latest one until Dart has handled it, the same pattern push-notification taps use, so cold launches work too. Home runs it only once the app is active and the destination is ready. When a tap arrives, Home pops back to the first route once, so a shortcut never opens beneath a Flutter sheet or a conversation that was already open. A screen that refuses to close (for example a New chat whose first message is still sending) stays, and the tap is dropped. The one exception is the native iOS New channel form, which isn't a Flutter route: a shortcut tapped with it open runs underneath it. If a screen is opened while a tap is still waiting for its destination, the tap is dropped. A New DM tap while the New DM picker is open replaces the picker. Opening the Bestie DM now goes through one shared function, so the Home launcher button and the shortcut run identical code. A second open while the DM is still being looked up does nothing. Signing out clears the items. The shortcut wiring lives in its own leaf widget, so Home doesn't rebuild for it. Behavior change for the existing Home Bestie launcher: if you open another screen while it's still looking up the DM, the DM no longer opens over that screen. Behavior change for the native member picker (New DM, and Add members in channels): a picker reopened while the previous one is still closing now opens once the dismissal finishes, instead of failing with "Cannot open the member picker". ### Validation - Added tests: a Home journey covers the published titles (including the Bestie's name), a cold tap that waits for the app to resume and for New chat to be available, a Bestie tap made with another route open, a New chat tap while a draft is open, a New DM tap replacing an open picker, a waiting tap dropped once another screen opens, no Home rebuilds on lifecycle changes, and clearing the items when Home goes away. A launcher test covers a slow Bestie lookup, and a New chat test covers a shortcut tapped while the first message is still sending. I also broke the code that runs the shortcut and confirmed the journey fails. - Full mobile suite and `flutter analyze` pass. CI doesn't compile Swift on drafts, so `NativeAddMembersSheet.swift` was compiled with a local `flutter build ios --simulator` build. - iPhone 17 Pro simulator (iOS 27), signed in to a Builderlab community: from a cold launch, New chat opened the new-chat draft. Then, with the app warm, New DM opened the picker and Message <Bestie> replaced it with the Bestie DM. The recording stays in Buzz because it shows real account data. 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: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
85c7156085 |
perf(mobile): stop downloading whole videos for posterless stills (#9367)
Follow-up to [#9356 gallery video selection](https://github.com/block/buzz/pull/9356), merged as [ |
||
|
|
4c60be6a1e |
Enforce single-community relay mode (#9149)
## Summary Add `BUZZ_MULTI_COMMUNITY_RELAY` (default `true`, strict boolean). With `false`, ordinary HTTP/WebSocket traffic serves only the community at the normalized `RELAY_URL` authority, including a nondefault port. Other hosts receive a generic 404 before tenant lookup or handler execution. Authenticated admin/operator inventory and cleanup remain available through their configured authorities. Existing extra rows do not prevent startup and are not deleted automatically. Canonical bootstrap and legacy convergence on the existing canonical community remain available; operator creation is disabled. Invite minting and git callbacks also check their target community. Availability retains its free-name meaning and adds `can_create` to report policy permission. Changes require restarting every serving revision. An older image can ignore this setting. Removing it, setting it true, or rolling back to an older image can reopen serving and creation. Keep operator/admin authentication configured throughout rollout and rollback. ### Current draft gates Published candidate `8319e9a4b299deb2bb2cb72400a2c6aff396b362` includes the independently reviewed deletion-fixture correction. Normal push hooks and all local validation passed at this head: 2,028 package tests, full `just ci`, all 1,393 isolated UTF-8/UTC PostgreSQL tests and the binary build. A running local relay passed 16 HTTP/WebSocket and signed admin/operator assertions, including extra-host denial and cleanup access. Hosted PostgreSQL and full CI must still pass on this head. Human acceptance and the background-work decision remain open; keep this PR draft. Background workers currently retain their prior behavior: cleanup/retention/recovery/revocation continue, as do scheduled workflows, reminders, configured push/listener delivery, storage-usage reporting and artifact sync under existing lifecycle rules. Whether to pause ordinary background work for extras is an open product decision and an acceptance gate. No worker changes are included yet. ### Rollout and acceptance 1. Resolve the background-work decision, finish validation/review, and complete the repository-required human test. 2. Obtain separate staging rollout authority, use an immutable supporting image and explicit false on every serving revision, and review generated configuration. 3. Verify an extra host returns 404 for HTTP and WS; canonical HTTP/WS and NIP-FI still work. Verify authenticated admin reads/actions and operator inventory/archive/delete still reach an extra community. 4. Only after staging acceptance, obtain separate production rollout authority. This draft grants no merge, deployment or cleanup authority. 5. Rollback by returning the setting to true or the previous image explicitly reopens serving/creation. Preserve operator/admin access. ### Related issue No duplicate creation-only policy PR found in the open-PR search. Related creation surface work: #2398. Creation-only validation is superseded; see its [historical CI run](https://github.com/block/buzz/actions/runs/42123475841). It does not qualify the current candidate. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
695c1c3aae |
fix(relay): match #a tag filters in SQL before LIMIT (#9339)
## Summary **Intent:** let clients find and count reactions to an **addressable event** at the SQL layer. Addressable events are NIP-01 kinds 30000–39999, which NIP-33 called "parameterized replaceable events". Clients reference one by its address, not its event id. An addressable event is identified by its address `<kind>:<pubkey>:<d>`. Each edit publishes a new event with a new id at the same address. A reaction (kind:7) that tags the address with `a` therefore still applies after the target is edited. A reaction that tags only the current revision's id with `e` is left behind on the old revision. Use cases: - **Agent personas (kind:30175).** A ⭐ on a shared persona is a kind:7 tagged `["a", "30175:<owner>:<slug>"]`. A client that lists shared personas by popularity needs `{kinds:[7], "#a":[...]}` and the matching COUNT to return every star across persona edits, not only stars on the current revision. - **Other addressable kinds**, such as teams and channel metadata, work the same way with no extra code. - **Replaceable events, such as user profiles.** Kind:0 profiles and kind:10100 agent profiles are *replaceable* (kinds 0, 3 and 10000–19999), not addressable. NIP-01 still gives them an `a` address with an empty `d`, `0:<pubkey>:`, so reactions that tag a profile that way are matched by the same filter. Before this change, the relay applied `#a` only after the SQL `LIMIT`. A selective read fetched the newest kind:7 rows across the whole community and then dropped the ones that didn't match. It could return fewer than `limit` matches, or none, while older matches existed. COUNT also could not take its exact fast path. What changed: - **`EventQuery.a_tags`** (buzz-db) works like `e_tags`. It is an exact positional tag match (`tag->>0 = 'a' AND tag->>1 = ANY(..)`) with JSONB `@>` as the prefilter on the existing GIN index `idx_events_tags_gin`. - **No schema change, migration or backfill.** - It gets the same MATERIALIZED CTE fence as `#e`, so the planner cannot walk `created_at` backward looking for a rare match. - It reads the table whole rather than recent partitions first. - It is counted in `count_events_sql`. - An empty set matches nothing, as the NIP-01 post-filter does. - **`filter_to_query_params`** fills `a_tags` for every filter, and **`filter_fully_pushable`** treats `#a` as pushed. REQ, COUNT and the HTTP bridge `/query` and `/count` all go through these two functions. - **`per_channel_scope`** now skips per-channel reads for every fenced query, through a shared `reference_tag_fence` helper. Before this, it excluded `#e` reads explicitly, and `#a` with a multi-value `#h` would have wrapped the fence around the per-channel lateral, which is invalid SQL. Not included: `#k` and other low-cardinality tags. Postgres's estimate for JSONB `@>` assumes any match is rare, so pushing a common value could switch to a slower plan. They remain post-filters. ### Related issue None found. ### Testing - `req.rs` unit test: `#a` maps to `a_tags` (including empty and absent) and is fully pushable; `#a` + `#k` is not. - buzz-db shape tests: an `#a` read is fenced, COUNT applies the match, an `#a` read is never split into recent and older passes, and `#a` makes a read ineligible for per-channel scope. An empty `#a` is answered without a database read. - buzz-db Postgres: an `#a` case added to `pushed_down_tag_filters_reject_positional_decoys`, covering query and COUNT. - New `handlers/tag_pushdown_postgres_tests.rs`: - REQ returns an older `#a` match ahead of a full page of newer reactions to another address. COUNT is exact, and an empty `#a` counts 0. This test fails when `a_tags` is removed from `filter_to_query_params`. - REQ and COUNT with `#a` + a multi-value `#h`. This test fails, with `CLOSED database error`, without the `per_channel_scope` fix. - Full buzz-db and buzz-relay suites, unit and Postgres lanes, run locally. - Live check against a local relay built from this branch, over WebSocket with NIP-42 auth. Two reactions to one address (each on a different head event, as after an edit) and six newer reactions to another address were published. `REQ {kinds:[7], #a:[needle], limit:2}` returned exactly the two needle reactions, newest first. COUNT returned 2 for the needle, 6 for the hay and 0 for an empty `#a`. `#a` + `#e` with `limit:1` returned the newest needle reaction. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jemiah Westerman <jemiah@squareup.com> Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
96e56c5b33 |
Add participant avatars to mobile conversation headers (#9369)
## Summary Add participant portraits to mobile conversation headers, with up to four portraits in a compact cluster. DM and Solo headers show counterparts; channel headers use the member roster. Names stay on one line, and tapping either the portrait or title opens the existing settings. - iOS: float portraits above the glass title capsule; lift standalone animated artwork and tuck the title underneath. - Android: place the cluster inside a left-aligned title pill, 16 dp after Back, with smaller agent portraits and a 48 dp tap target. - Remove header presence/member-count subtitles. In confirmed two-member conversations (DM, Solo, or channel), hide redundant incoming author avatars and give text the freed 48 dp while retaining the previous total width. Apply this to thread replies and align their footers; messages with avatars and outgoing messages retain their production width caps. Keep sender attribution visible while DM metadata and current membership disagree. Forum loading, error, and post-list states reserve the full conversation header height. The floating title follows the app-selected appearance, including when it differs from iOS. Conversation headers remain name-only, including temporary conversations, as requested; retention accessibility text is preserved. ## Validation - Formatting/static analysis and full mobile suite: 4,695 passed, 71 skipped; auxiliary push and crypto suites passed. - Full native iOS suite passed, including title positioning/reset and settings activation. - Installed previews on physical iPhone Air and Pixel; Android avatar/title settings taps exercised. - Final diff reviewed for correctness, accessibility, scope, and public-source boundaries; no blocking findings. The width follow-up is covered by rendered conversation/thread tests. The width follow-up is installed and launched on iPhone Air; the Pixel preview remains on the preceding header build. The review fixes have not been installed on physical devices. ## Snapshots Current Pixel preview, cropped to the header to omit conversation history. These captures show a single agent and a four-portrait cluster. The current iOS preview is installed, but a fresh physical-device screenshot was blocked by device authentication; no outdated iOS capture is included. ### Android — single agent  ### Android — grouped agents  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
8f80b172ea |
fix(mobile): stop asking for an API token when the channel list is refused (#9327)
## Summary - When the relay refused the channel-list request (HTTP 401), the mobile app said "Not authorized. Check your API token." The app has no API token, so the advice could not be followed. - The message now reads "This community couldn't authenticate the request. Try again." Retry stays: for org and hosted communities the refused token is dropped and Retry gets a fresh one, and a self-hosted relay that refuses the key has no other recovery on this screen. - One exception: when the organization sign-in has ended, the app sends reads unsigned and Retry can never succeed, while the Home banner already offers a working Sign in. In that state the channel list says "Use the sign-in prompt below to reconnect." and hides Retry. ## Details - `_ErrorView` (`mobile/lib/features/channels/channels_page/badges.dart`) only appears on a cold load with no saved channel list. It replaces the list, so Retry is the only control on that screen. - It reuses the shared `organizationSignInPromptVisible` predicate, the same one the Home banner and the community switcher use, so the error view and the banner agree on who owns the action. - The 403 "Access denied." message is unchanged. - Widget tests cover the new 401 copy with Retry, and the signed-out organization state with no Retry. ## Screenshots Android emulator, debug build, against a local test relay that refuses the channel-list read with HTTP 401 (no saved channel list, so the error view shows): <img src="https://github.com/user-attachments/assets/feebc324-a4cf-49ca-876a-1ae026bda753" alt="Channel list error: This community couldn't authenticate the request. Try again. With a Retry button" width="360"> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
75d38b6c2d |
chore(release): release Buzz Relay version 0.3.0 (#9362)
## Summary
Prepare Buzz Relay 0.3.0 from freshly fetched `main` at
`e5dee91c90fdc5f81f04fd609b7c99e31fa7570f`. Bump only the relay crate
version and its root lockfile entry from 0.2.1 to 0.3.0, and prepend the
relay changelog. Desktop, CLI, mobile, Helm, and dependency versions are
unchanged.
The changelog contains all 297 non-merge commits selected by the relay
release recipe from `relay-v0.2.1` through the pinned base. Internal
ticket identifiers were removed from historical subjects to follow the
repository's public-source policy; entries and PR/full-SHA source links
are preserved. [Full source
comparison](https://github.com/block/buzz/compare/relay-v0.2.1...e5dee91c90fdc5f81f04fd609b7c99e31fa7570f).
### Related issue
Release preparation request; no duplicate release 0.3.0 PR found.
### Testing
- `just bump-relay-version 0.3.0`: completed; the lockfile changes only
the `buzz-relay` version, without transitive dependency updates.
- `cargo metadata --locked --no-deps --format-version 1`: confirms relay
version 0.3.0.
- `git diff --check` and mandatory pre-commit formatting: passed.
- `bash scripts/test-release-ref-contract.sh` passed in a clean local
clone of `3def95c9435b2884c01446fe1a1f92f4fe9960fc`.
- Mandatory pre-push checks passed at
`3def95c9435b2884c01446fe1a1f92f4fe9960fc`. Independent review of the
commit and published head/body/draft state found no defects.
Repository-wide `just ci` passed at
`3def95c9435b2884c01446fe1a1f92f4fe9960fc` after reproducible dependency
setup; the worktree remains clean. [GitHub CI attempt 2
passed](https://github.com/block/buzz/actions/runs/43092559585/attempts/2)
at that exact head. The prior attempt failed before tests in three
artifact downloads; retrying the failed jobs resolved the service errors
without code changes. Local checks and independent review passed; human
acceptance remains separate. The existing ready state is preserved.
Local integration validation is blocked: Docker Desktop starts and its
daemon answers, but managed sign-in policy denies container access until
the operator signs in.
### Release hazards
Preparation only: **merging this PR automatically creates `relay-v0.3.0`
and starts image publication**, including stable version aliases and
`latest`/`debug-latest`. No merge, tag, or deployment is part of this
preparation.
The [relay
tagger](
relay-v0.3.0
|
||
|
|
ef57fec4d2 |
Fix gallery video selection, thumbnails, and swipe dismissal (#9356)
## Summary Photos currently filters out videos, video messages without poster metadata stay grey until playback, and the native gallery rejects downward dismissal on video pages. - Include photos and videos in the native inline/popover pickers, system picker, and recent-media grid. Mixed selections retain their order and videos use the video upload path. Remove the redundant Video menu row on iOS and Android, whose Photos picker accepts both media types; reduce their menu height to four rows. Native pickers request the current asset representation to avoid compatibility transcoding and export up to three files concurrently, preserving order and cleaning up cancelled/failed batches. - Show local stills for queued videos. For older messages without posters, serialize thumbnail generation for the four rendered stack cards, bound/cancel downloads, remove temporary videos, and save stills in the existing account/community cache. Existing poster metadata is used directly. The native viewer uses the poster while loading instead of a play-button snapshot. - Allow downward dismissal on native video pages while preserving horizontal paging/scrubbing, zoomed-photo panning, and the accessible Close button. Fire selection-click haptics as a swipe begins rather than after the page settles, with no duplicate on completion or cancellation. Page-indicator changes retain immediate feedback. Replace visible video-loading text with the existing white arc-spinner treatment, implemented natively with reduced-motion and lifecycle handling. Completed swipes release the previous video player even after UIKit removes it from the active children. Legacy videos need one initial download (up to the existing 100 MB limit) to generate their cached preview. This does not start playback. ### Related issue Follow-up to merged #9259 and #9314. Searched open and closed gallery/video issues and PRs; no duplicate fix found. ### Testing - Full `just mobile-check` and `just mobile-test` passed at `12e6ed6ae3`: 4,688 Flutter tests (71 skipped), 4 unconfigured-push tests, and 27 crypto-package tests. File-size checks passed. Regressions cover mixed image/video upload routing, queued still previews, the rendered-card poster window, authenticated/cancelled downloads, caching/cleanup, and native poster loading. - Native gallery simulator regression fixture passed: photo/video dismissal guards, real MP4 decoding, paging, selected-only playback loading, and teardown. - Swift type-check of the native picker and its dependencies passed, with existing deprecation/capture warnings. - `just ci` passed its repository-wide lint/type checks. The unrelated Rust test rebuild was stopped after consuming about 13 GB to preserve remaining disk space; only that run's generated caches were removed. Release build at `fb09f9cb8` installed in place and launched on the physical iPhone Air as **Buzz Photo Stack**, with `BUZZ_NATIVE_PHOTO_ZOOM=true`. Existing app updated in place with the same bundle and Keychain identity; no uninstall or reset. Owner tested the physical-device preview and approved readiness for review. No before/after capture was produced. The simulator evidence above is an automated native fixture, not a full Buzz device journey. Native preparation follow-up: `scripts/test-mobile-picker-preparation.sh` passed on an iPhone Air simulator, covering bounded overlap, out-of-order completion, cancellation, and failure cleanup. The updated physical Air preview launched successfully with unchanged preferences metadata. Cloud-only assets may still require preparation. Loader/haptics follow-up native fixture passed: real video readiness hides the loader, error/retry and detach stop/restart it correctly, swipe start triggers one haptic before selection changes, and completion/cancellation/duplicate-start callbacks add none. Updated release installed and launched on the physical Air; owner approved the final preview for review. Review: focused code review and native regression checks completed; physical Air preview tested by the owner, who approved readiness. P2 follow-up: video presentation uses an already decoded poster or the source snapshot immediately, then forwards a pending poster asynchronously. Late poster updates are limited to the selected native page and do not restart playback. Added pending-provider presentation coverage and native late/stale-poster checks. This follow-up has not been installed on the physical preview yet. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
e5dee91c90 |
chore(deps): bump hickory-proto to 0.26.2 for RUSTSEC-2026-0487..0491 (#9351)
## Summary - The dependency-policy check (cargo-deny) on `main` fails because `hickory-proto` 0.26.1 now has five published security advisories: RUSTSEC-2026-0487, -0488, -0489, -0490 and -0491 (panics and memory amplification when parsing malformed DNS records or zone text). - This updates `hickory-proto` to 0.26.2, which carries all five fixes. With it, `cargo-deny check` passes again (advisories, bans, licenses and sources all ok). - The desktop app's separate Rust workspace (`desktop/src-tauri`) gets the same update. CI does not run cargo-deny there, but release builds include `hickory-proto` through the `mesh-llm` feature, so they would otherwise still ship 0.26.1. ## Details - `hickory-proto` is a transitive dependency: `iroh` → `hickory-resolver` → `hickory-net` → `hickory-proto`. No crate names it directly. `hickory-net` and `hickory-resolver` stay at 0.26.1; they accept `hickory-proto ^0.26.0` and get the fixes through it. - Lockfile-only change: the `hickory-proto` version and checksum in each lockfile. 0.26.2 has the same dependency list as 0.26.1, and the locked versions already meet its raised minimums. A plain `cargo update -p hickory-proto` also moved ten unrelated Windows-only crates from `windows-sys` 0.59 to 0.61; those are left out to keep this change to the advisory fix. - 0.26.2 is not behavior-identical: it rejects malformed DNS input that 0.26.1 panicked on or over-allocated for, and it treats IPv4-mapped IPv6 addresses as IPv4 in resolver answer filtering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) 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> |
||
|
|
95dd9d67dd |
feat(mobile/ios): show the message's own text in notifications (#9346)
Expanding a Buzz notification today shows the same stub as the banner: one line of at most 180 characters, with code replaced by `[code]` and links by `[link]`. A long message can only be read by opening the app. The notification body is now the message's own text. It keeps line breaks, the text of code and bare URLs, and drops the Markdown syntax around them (`[text](url)` shows `text`). It is capped at 256 UTF-16 units, which is the most iOS shows even when a notification is expanded. iOS cuts longer bodies mid-word with no ellipsis, so the cap stops at 255 units and adds "…" without splitting a character. A link label, image alt text or bold run may contain code, and a code fence with no closing fence runs to the end of the message. The body is built from at most the first 4096 UTF-16 units of the message, so very long or hostile Markdown stays fast (under 300 ms for a 256 KB message in an optimized build); when that cut drops text, the body also ends with "…". | Before (expanded) | After (expanded) | | --- | --- | | <img width="300" alt="Before" src="https://github.com/user-attachments/assets/7201791f-9556-446a-86d2-a50f024cf9dc"> | <img width="300" alt="After" src="https://github.com/user-attachments/assets/3fa52db8-ebcf-470f-9175-47fe5ef4ed12"> | [ADR 0002](docs/adr/0002-notification-message-text.md) records the rules so other mobile platforms follow them, and the cases in `test-fixtures/notification-body.json` are the contract. The iOS tests run every case, and Android's notification code can run the same file when it lands. **What gets better, what doesn't get worse:** only the body text of message notifications changes. The title, subtitle, grouping, avatars, navigation and dedupe are untouched, and the change lives in one pure function. A notification can now show code and URLs from the message, as the message itself does, and the user's lock-screen preview setting still applies. ### Validation - Added tests: `testPreviewBodyMatchesSharedNotificationBodyCases` runs the 30 shared cases (links, mentions, images, fenced and inline code, code inside link labels and bold runs, Markdown inside code, unclosed and cut fences, whitespace, the cap at 256 and 257 units, emoji and ZWJ sequences at the cap, the 4096-unit input cut). The full BuzzPushKit suite passes: 112 XCTest and 70 Swift Testing tests. - iOS limit measured on an iPhone 17 Pro simulator (iOS 27) with a local notification: the expanded body and its accessibility label both stopped at exactly 256 UTF-16 units (256 ASCII characters, 128 emoji), cut mid-word with no ellipsis. A 40-line body showed about 31 lines. - The screenshots show this function's before and after output for the same message, posted as a local notification. The simulator doesn't run the Notification Service Extension for `simctl push`, so the full push path (relay fetch through to display) was not exercised. An iPad was not measured. - Human testing is waived; the automated run above covered display of the changed body only, since nothing else in the app changes. 🤖 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> |
