mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
e8a4bc15917e61dc0b8066e24c026dc6f3e7ce36
3396 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e8a4bc1591 |
ci(docker): skip relay image publishes for main pushes outside the build context (#9310)
## Summary Every push to `main` published a new relay and push gateway image, even when it only changed `desktop/`, `mobile/`, `docs/` or other paths `.dockerignore` keeps out of the Docker build context. Deployments that track the sortable `main-<commit UTC>-<7>` tag then rolled their relays onto an image built from identical inputs. The `push` trigger now skips the directories excluded from the build context (`desktop/`, `mobile/`, `docs/`, `bin/`, `sidecars/`, `.github/`) and re-includes `.github/workflows/docker.yml`, which defines the build. Tag pushes (`relay-v*`) still always publish, because GitHub does not evaluate path filters for tag pushes. Pull request builds keep their existing allowlist. ## Guard `scripts/test-relay-image-eligibility-workflow.sh`, which `ci.yml` runs, now requires the push filter to be exactly, in order: 1. `"**"`, 2. only `"!<dir>/**"` exclusions, each also listed in `.dockerignore`, 3. the `.github/workflows/docker.yml` re-inclusion. Comment and blank lines inside the list are skipped rather than ending it. So nobody can add a file exclusion, exclude a directory Docker sees, or reorder the re-inclusion in a way that hides a change to the image. ## Testing - `scripts/test-relay-image-eligibility-workflow.sh` passes. - The guard fails with a specific message for each of these: `"!crates/**"` added, `"!Cargo.lock"` added, single-quoted `'!Cargo.lock'`, the re-inclusion moved before `"!.github/**"`, `"**"` removed, and `"!crates/**"` appended after a comment or a blank line. - `actionlint` passes on `docker.yml`. ## Simpler The image is published only when its inputs can change. This removes no-op staging rollouts, plus the cancelled or queued Docker runs that each merge to main caused. No new concepts: the filter reuses `.dockerignore` as the only definition of what the image sees. Identified follow-up: root-level Markdown (for example `README.md`) is in the build context, so root-docs-only merges still publish. Skipping them would need a `.dockerignore` entry and a file-level filter rule, which the guard currently rejects on purpose. 🤖 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> |
||
|
|
3e9d2e90ce |
Add manual-only relay image publishing (#9303)
## Summary Add **Manual relay image**, a manual-only GitHub Actions workflow that builds and uploads a Buzz relay image from a chosen repository branch, tag or full commit SHA. It does not wait for normal CI checks or require the chosen source to be merged to `main`. This PR changes only `.github/workflows/docker-manual.yml`. It adds no scripts or runbook. The normal Docker release workflow and existing staging-only workflow are unchanged. ### What the workflow does 1. Resolves `source_ref` to one exact source commit. 2. Builds the existing Dockerfile's `runtime` target on native AMD64 and ARM64 runners. 3. Uploads each architecture to `ghcr.io/block/buzz` using the existing `GITHUB_TOKEN`. 4. Publishes a unique `manual-<source-sha12>-<run-id>-<attempt>` tag when both builds succeed. 5. Verifies that the published image contains exactly one runtime for each architecture and that both runtime source labels match the selected commit. 6. Reports the source SHA, registry tag and combined digest in the run summary and a downloadable JSON artifact. There are no push, pull-request or scheduled triggers. The workflow does not run the test suite, wait for `ci.yml`, or require a successful CI qualification. The source must still build successfully, and image verification must succeed before the workflow reports success. ### Run from GitHub UI 1. Open [Buzz Actions](https://github.com/block/buzz/actions). 2. Select **Manual relay image**. 3. Click **Run workflow** and select `main` or the repository branch containing the workflow definition you want to run. 4. Enter your block/buzz branch, tag or full SHA in **source_ref**. Example: `fix-db-timeout`. The commit must be reachable from a repository branch; PR refs and PR-only commits are rejected. 5. Click **Run workflow** and wait for both builds and **Publish and verify the combined image** to succeed. 6. Open the run summary and copy its registry tag and digest. **`source_ref` is the code you want to build.** The workflow branch selects the build instructions and may be non-main; CLI `--ref` selects that branch. Your source branch does not need to merge. This new workflow appears in the UI once its definition is on the default branch. ### Run from CLI You need GitHub CLI authenticated as a collaborator who can run workflows. No repository checkout, laptop Docker setup, AWS login or personal package-publishing token is required. ```bash # First-time GitHub CLI login only; skip if already authenticated. gh auth login --hostname github.com --web # Replace this with your Buzz branch, tag or full commit SHA. export BUZZ_SOURCE_REF='fix-db-timeout' gh workflow run docker-manual.yml \ --repo block/buzz --ref main \ -f source_ref="$BUZZ_SOURCE_REF" # Find YOUR new run; its title includes the selected source_ref. gh run list --repo block/buzz --workflow docker-manual.yml \ --event workflow_dispatch --limit 5 export BUZZ_RUN_ID='REPLACE_WITH_YOUR_RUN_ID' gh run watch "$BUZZ_RUN_ID" --repo block/buzz --exit-status gh run view "$BUZZ_RUN_ID" --repo block/buzz --web ``` ### Source and workflow selection Dispatch may use `main` or another branch in `block/buzz`. CLI `--ref` selects the workflow definition; `-f source_ref=...` separately selects the code to build. For example, `--ref feature/build-tooling -f source_ref=fix-db-timeout` runs the feature branch's workflow against the fix branch's code. The dispatcher is responsible for selecting trusted workflow instructions. The resolver has no package-write permission and runs no selected-source scripts. It validates the input, fetches canonical branch histories, and proves the selected commit is reachable from a repository branch before either publishing job can start. A full SHA cannot bypass the PR-ref restriction. Tags that point only to unreachable or PR-only commits are rejected too. Before this new workflow is registered on the default branch, its exact definition can be tested through the existing workflow path on the isolated test branch: ```bash gh workflow run docker.yml --repo block/buzz \ --ref codex/manual-relay-image-test -f source_ref=fix-db-timeout ``` This runs a fresh build of the selected repository source. It does not modify the default branch's normal workflow. ### Image output and deployment The summary reports an immutable reference such as `ghcr.io/block/buzz@sha256:...`. The `manual-image-<run-id>-<attempt>` artifact contains the same digest, source identity and per-architecture digests. Keep the actual `manual-*` tag as deployment/version metadata alongside the digest. No synthetic `sha-*` deployment tag or registry alias is emitted. The image registry is shared by consumers; the workflow does not select staging or production. Downstream deployment configuration selects the image digest and environment. This workflow does not edit deployment manifests, sync Argo CD, restart workloads, overwrite normal release tags, or upload directly to AWS ECR. Any downstream cache import and deployment are separate operations. Manual images do not receive the normal CI deployment-eligibility attestation. They are not automatically eligible for canonical promotion. The selected code and any skipped checks must be evaluated before an explicit deployment. ### Testing The earlier implementation completed a real two-architecture build and publish in [run 42704887052](https://github.com/block/buzz/actions/runs/42704887052). Its image was also verified in the downstream ECR cache. The build-and-publish path passed [run 42713322329](https://github.com/block/buzz/actions/runs/42713322329) for unmerged source `beda6eeaec8ca4a00231a05a079eb168d2a6aec2`. Both native builds and combined-image verification succeeded. That run predates the source restrictions added during review. The current resolver and workflow-origin guard were executed directly from this YAML against local Git fixtures: 19 source cases and 3 origin cases passed. These include accepting internal branches/tags/ancestor SHAs and rejecting PR refs, the raw SHA of a PR-only commit, unreachable tags, malformed inputs and a foreign repository. Main and non-main workflow refs are both accepted in block/buzz. Live resolution of a block/buzz feature branch also passed. The exact image verifier passed valid-image cases and rejected missing/duplicate platforms, wrong architecture and wrong source. No deployment was started. Full local `just ci` stopped at the existing mobile formatting check (exit 65), with an unresolved `flutter_lints/flutter.yaml` include. The repository-wide check is not green; no mobile source was changed by this PR. ### Related issue No matching issue found. The existing staging-dev workflow publishes to a distinct staging-only package and accepts branches/tags; this workflow adds general manual publication and raw commit selection. <img width="1465" height="821" alt="Screenshot 2026-11-09 at 5 55 42 PM" src="https://github.com/user-attachments/assets/6a78b31f-9646-4f19-b44a-116c16818331" /> --------- Signed-off-by: Ravneet Arora <rarora@squareup.com> |
||
|
|
402bd1897f |
fix(mobile): bump Flutter to 3.47.9 for Android TLS fix (#9309)
Flutter 3.47.7's bundled Dart TLS verifier on Android misses Android's compromised-intermediate blocklist and, on Android 14+, reads the older CA directory ([GHSA-frr6-h895-5pxp](https://github.com/dart-lang/sdk/security/advisories/GHSA-frr6-h895-5pxp)). An on-path attacker can therefore impersonate the relay to Android builds with a certificate chained to the compromised eMudhra intermediates. Flutter 3.47.8+ ships the patched verifier. This bumps the hermit pin to the latest patch, 3.47.9 (Dart 3.13.7), and raises the SDK floor in `pubspec.yaml` and CONTRIBUTING to match. Tracked in BSEC-29. iOS and macOS use Apple's certificate verification and aren't affected by this advisory. Patch-level bump within 3.47: no source, formatter or dependency changes were needed; `pubspec.lock` only changes the Flutter floor. No visible change, so no screenshots. ### Validation - `flutter analyze`: no issues. `dart format`: no changes. - Full mobile suite on 3.47.9: 4,596 passed, 67 skipped. - Not done: an Android device run that repeats the eMudhra interception test against a 3.47.9 build. 🤖 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> |
||
|
|
773849665e |
Serve video and poster previews for invite links (#9306)
Invite links currently return HTML without preview metadata. Add server-rendered `og:video` and `og:image` tags for `/invite/<code>`, and bundle the approved MP4 and wordmark-only poster from #9282 into the relay image. Media URLs use the request’s resolved community host, with only TLS posture from `RELAY_URL`. Unknown hosts and lookup failures receive no metadata; previews stay generic and never redeem invites. Validation: production web build, workspace Clippy, 52 router tests (including real HTTP and MP4 range requests), metadata tests, and independent agent review pass. `just ci` stops at unrelated mobile dependency/format checks. The full relay suite was attempted but stopped during database-dependent tests without local infrastructure. Broad local Rust/desktop hooks were stopped for disk pressure; desktop/mobile hooks also lack dependencies and were skipped to publish this draft. Full-stack and native Messages playback remain unverified. The new preview regressions run in both CI test-runner paths. Rollout: merge and deploy the resulting relay image with its web bundle, verify the invite HTML and both public media URLs, then share a fresh invite in Messages. Full-stack and human validation remain outstanding. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
18f3e35413 |
docs: point the README to Buzz 1.0 in block/buzz-app (#9296)
The README doesn't mention Buzz 1.0, so anyone who lands here can't tell
that a newer app exists. The block/buzz-app README already links back
here ("Buzz 0.X is still available and supported in block/buzz"). This
adds the link the other way: a note at the top that Buzz 1.0 lives in
block/buzz-app and that this repository is the 0.X line, which is still
supported. It also says plugins are new in 1.0 and their API is a
preview, linking to buzz-app's plugin architecture doc. This repository
has no plugin API, so the note points there instead of repeating
buzz-app's stability details.
Docs only, a 4-line README change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Diem Nguyen <diem@squareup.com>
Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
5b1698ec24 |
fix(db): give the replica-fence probe its own pool and instrument it (#9297)
## Problem
The replica-fence probe (`replica_fence::run_probe`) commits a heartbeat
token every 500 ms. It borrowed its connection from the **serving writer
pool**, so under load each beat queued behind request traffic.
In a production deployment during a reconnect/replay storm:
-
`buzz_db_pool_acquire_duration_seconds{pool_role="writer",operation="maintenance"}`
went from ≤50 ms max to 2.7–3.0 s, which is the writer pool's acquire
timeout.
- `buzz_db_replica_heartbeat_age_seconds` went from about 0.47 s to
1.25–3.02 s, in the same 10 s buckets.
- Bounded reads then failed the freshness check
(`buzz_db_route_decision{decision="writer",reason="stale"}` went from
0–6 to 1.3k–6.4k per 10 s), so read traffic spilled onto the writer
exactly when it was saturated. Replica lag stayed at tens of
milliseconds the whole time.
- No `replica fence probe failed` warning fired, because each beat
eventually succeeded, just late. The probe had no span or metric of its
own (`maintenance` is shared by 15+ call sites), so this was only
visible indirectly.
## Change
- **Dedicated pool** (`replica_fence::connect_probe_pool`):
- lazy, one connection, `application_name=buzz-replica-fence-probe`;
- same credentials and `max_lifetime` as the writer, so it follows
failover;
- modelled on the push producer's pool.
- It doesn't run the writer pool's `after_connect`: the probe never
writes `events`, so no floor-guard arming is needed. Instead it installs
its own session timeouts:
- `lock_timeout` 1 s. While the probe waits for the exclusive floor
lock, new channel writes queue behind it, so failing a beat fast is
cheaper than holding the queue for the serving writer's 5 s budget.
Retained proofs stand.
- `statement_timeout` and `idle_in_transaction_session_timeout` 2 s. The
idle timeout reaps a stalled client that still holds the exclusive lock.
- A 5 s client-side deadline bounds the whole probe.
- Cost: one extra writer connection per relay process.
- The `Db::connect_writer_pool` doc now names this pool and the
push-enqueue pool as deliberate exceptions to its "every writer pool
uses this constructor" rule, and says why.
- **Spans** (via `datastore_span`, which also records
`buzz_db_operation_duration_seconds{operation=…}`):
- `replica_fence_probe` carries `acquire_ms`, `lock_ms`, `scan_ms`,
`heartbeat_ms`, `commit_ms`, `beat_gap_ms`, `token` and `error_kind`.
- Child spans `replica_fence_probe_acquire` and
`replica_fence_probe_lock` cover the two waits.
- **Logs:**
- Failure: `warn` with `error_kind` (`acquire_timeout`, `lock_timeout`,
`statement_timeout`, `deadline`, `masked_activity`, …),
`consecutive_failures`, `heartbeat_age_ms` and per-phase timings.
- Recovery after failures: `info`.
- **Reader spill metric:** new `buzz_db_reader_spill_total{path,
reason}`. It counts only the routed reads the replica could have served
but the writer did. `buzz_db_route_decision{decision="writer"}` already
existed but merges three different causes under `reason="stale"` and
also counts configuration (`disabled`). Reasons:
| Reason | Meaning |
|---|---|
| `heartbeat_stale` | The newest beat is older than the bounded-read
budget. The probe is late, not the replica. |
| `heartbeat_missing` | No live proof: before the first beat, or nothing
within `FENCE_STALENESS`. |
| `fence_wall_behind` | The read's upper bound is newer than the fence
wall. |
| `replica_lag` | The session proved only an older entry than the read
needs. |
| `reader_acquire_timeout` | Couldn't check out a reader connection. |
| `reader_proof_timeout`, `reader_token_behind`,
`reader_validation_error` | The reader couldn't prove the fence. |
`buzz_db_route_decision` keeps its labels and values unchanged for
existing dashboards. History reads rejected on a saturated reader stay
`decision="rejected"` and aren't counted as spills.
- **Heartbeat interval versus budget:**
- The problem: at 500 ms beats, a 1000 ms `BUZZ_REPLICA_READ_MAX_AGE_MS`
tolerates no missed beat. Just before each commit the newest entry is
already about one interval old, so a single late or failed beat puts
every bounded read over budget.
- The default beat interval stays at **500 ms**. Every beat takes the
deployment-wide exclusive floor lock that channel writes queue behind,
so beating faster costs more lock drains. The budget is what should
absorb a slow beat.
- New `min_read_budget(interval)` = interval + `PROBE_LOCK_TIMEOUT` (1
s) + 200 ms, which is **1700 ms** at the default. That covers one beat
that waits out the lock timeout, plus commit latency. `Db::new` warns at
startup when the configured budget is below it. A deployment running a
1000 ms budget will see this warning until the budget is raised.
- The interval is now configurable with
`BUZZ_REPLICA_FENCE_PROBE_INTERVAL_MS`. Unset means the 500 ms default.
Values outside 300–10000 ms fail startup. The 300 ms floor keeps the
120-entry proof ring covering more than the 30 s staleness gate, and the
ring assertion checks against that floor.
The proof ordering is unchanged: exclusive lock, then `S`, then activity
scan, then token commit last. `committed_at` is still captured before
the `UPDATE` round trip.
## Tests
- New `fence_probe_beats_while_serving_writer_pool_is_exhausted`: goes
through `Db::new` + `spawn_fence_probe`, holds the serving pool's only
connection, and requires two fresh beats and exactly one probe session.
It fails on `main`'s wiring, which I checked by temporarily reverting
the `spawn_fence_probe` change.
- New `cluster_global_probe_pool_bounds_the_floor_lock_wait`: a held
shared floor lock makes the probe fail with `lock_timeout` after about 1
s, the retained proof survives, and the next beat succeeds after
release.
- New `reader_spills_are_counted_by_cause`:
- fresh heartbeat → no spill;
- late heartbeat → `heartbeat_stale`, with the legacy `stale` still
emitted;
- past the staleness gate → `heartbeat_missing`;
- routing disabled → no spill.
- Unit tests cover the log throttle and the interval/budget helpers. The
epoch-rotation test now runs through the one-connection probe pool.
- The `observability_source` source-policy test now asserts the probe
never takes a serving-writer `Maintenance` checkout and is spawned
through `connect_probe_pool`.
- Existing replica-fence and `spawn_fence_probe` tests pass unchanged,
apart from signature updates.
## Not done / follow-ups
- Locally: `buzz-db` unit tests and clippy (`-D warnings`) pass, and 38
replica-fence and routing Postgres tests pass on a fresh database. I
didn't run the full Postgres suite, because it needs the nextest
per-test-database wrapper, so CI's Postgres lane is the gate there.
- No local relay run yet. This needs a reader-configured relay, then
pool exhaustion to see the spans and logs end to end.
- The deployed `BUZZ_REPLICA_READ_MAX_AGE_MS` lives in deployment
config. Check it against the new startup warning.
- The `PROBE_DEADLINE` path has no test. It's only reachable through a
stall that no server-side timeout catches.
🤖 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>
|
||
|
|
dd9da3ac6d |
fix(relay): never close connections when cross-node fan-out falls behind (#9302)
## Summary Cross-node fan-out no longer closes connections when it falls behind. Before this change, an event closed its local recipients when: - it was not delivered within 30 s of this node reading it from Redis, - its worker queue was full, - or the access gate still could not check a recipient by that deadline. Dispatcher lag also closed every subscribed connection. A rate-limited closer spread the closes out, at 1% of a node's connections per second with a floor. The trouble is that a database stall slows every node's deliveries at once. So the closes landed on every node together. Each reconnect then does AUTH, membership reads, NIP-11 and REST catch-up against the database that was already slow. That turned a short stall into a reconnect storm and bursts of 503s. On small nodes the closer's floor dominated, so a node could close all of its connections within tens of seconds. Review-Proven Rule 9 (design every client-driven path for the whole fleet running it at once) is the reason: a server-forced close starts a fleet-wide reconnect. ## What changes Kept from #9195 and #9235: - One dispatcher plus 16 workers, routed by `(community, topic)`. Each channel stays in order and channels deliver concurrently. - The bulk membership read in the access gate, and denying an event for a channel that no longer exists. Removed: - The delivery deadline and `ChannelEvent.received_at`. - The live fence (`fence_live_delivery`, `live_fenced`). - `LagCloser` and its rate limit. - The queue-full closes and the dispatcher-lag closes. - The access-gate retry loop (`buzz_multinode_fanout_gate_retries_total`). Behavior now: - A slow delivery holds only its own worker and arrives late. - A full worker queue or dispatcher lag drops the event on this node. The drop is counted in `buzz_multinode_fanout_dropped_total{reason="queue_full"}` or `buzz_multinode_fanout_lag_total`, and the affected artifact syncs get `CLOSED "resync: live delivery lagged"` (NIP-AR Sync rule 5). This is the same as before the closer existed. - A recipient the access gate cannot check misses that event, and a sync among them resyncs. Same as the local fan-out path. Trade-off: a plain subscription that misses a dropped event is not told. Its client may replay the event on its next reconnect, but not if later deliveries moved its resume cursor past the event by more than its replay slack (5 s for the ACP client). That was the behavior before #9195. Queue-full and lag drops stay visible in the counters, so we can see whether they actually happen before adding any recovery mechanism. ## What got simpler Net −700 lines. - `multinode_fanout.rs` drops from ~940 lines to ~350. - `deliver_pubsub_event` loses its retry loop and goes back to the shared `route_fanout_matches` path. The Redis path and the local path once again apply the access gate the same way. - `ConnectionManager` loses the fence flag and the split send helper. `SubscriptionRegistry` loses `subscribed_connections`. ## Testing - `handlers::multinode_fanout::tests`: - in-order delivery through the workers; - a full queue resyncs only the matched syncs and closes nothing, and a local echo is not counted as a drop; - dispatcher lag resyncs every sync and closes nothing; - a recipient the gate cannot check misses the event with no retry, stays connected, and still gets the next event. - `handlers::event::tests` is unchanged apart from removing `received_at`. - Mutants killed: the queue-full resync, the lag resync and the local-echo skip. 🤖 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> |
||
|
|
85996fdff4 |
test: fix timing-dependent unit tests in buzz-cli, buzz-agent and buzz-admin (#9273)
## Summary
Split out of the `flakey-monday` flake-fix series. Three Rust unit tests
in `buzz-cli`, `buzz-agent` and `buzz-admin` depended on wall-clock
timing or on a production-sized pool budget and failed on loaded CI
runners. Each now waits on, or counts, the thing it actually asserts.
Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(cli): throttle the first desktop sign-in by count, not clock`
- `test(agent): wait for both responses in
steer_rejected_on_empty_prompt`
- `test(agent): pin steer_rejected_on_empty_prompt to the empty-prompt
guard`
- `test(admin): give the NIP-FI Postgres tests a test-lane pool budget`
### Root cause and fix per test
**`client::desktop_throttle_tests::a_throttled_sign_in_reconnects_with_fresh_evidence`**
(`crates/buzz-cli/src/client.rs`): failed once in CI with `conns` left
1, right 2.
- Root cause (confirmed): the fake desktop throttled a sign-in only when
`last.elapsed() < 300ms`, and `last` was captured before the provider
handshake and credential setup. When that setup took over 300 ms, the
first sign-in was signed straight away, nothing was throttled, the
client never reconnected, and the relay saw one connection.
- Fix: the fake desktop throttles exactly its first `sign_event` call
via an atomic counter, which is the scenario the test describes. The
relay's unsigned-socket deadline goes from 200 ms to 5 s, as in the
sibling deadline tests, so it no longer shapes the scenario. Assertions
are unchanged: two connections, only "c1" signed, a fresh assertion on
the reconnect.
**`steer_rejected_on_empty_prompt`**
(`crates/buzz-agent/tests/fake_llm.rs`): panicked with "empty steer
prompt was not rejected".
- Root cause (confirmed): `session/prompt` runs in a spawned task while
`_goose/unstable/session/steer` is answered inline from the stdin loop.
The fake LLM finishes the turn almost at once, so the prompt response
can arrive before the agent has read the steer. The receive loop broke
on the prompt response and never saw the steer's -32602.
- Fix: keep reading, bounded at 40 messages as before, until both the
steer and prompt responses have arrived in either order. The follow-up
commit also asserts `error.message == "steer: prompt must not be
empty"`, because `steer_session` returns -32602 for "no active run to
steer" and an `expectedRunId` mismatch too, which are exactly the
replies a steer gets when the turn ends first. The empty-prompt check
runs right after decoding, so the message is deterministic in both
orderings.
**`nip_fi::postgres_tests::{backfill_binds_only_bindable_hosts_and_reports_the_rest,
backfill_skips_a_community_being_deleted,
bind_refuses_hosts_outside_the_suffix_and_unbind_keeps_the_last_issuer}`**
(`crates/buzz-admin/src/nip_fi.rs`): `connect to test DB:
Sqlx(PoolTimedOut)` after about 3.5 s.
- Root cause (confirmed): the tests built their pool from
`DbConfig::default()`, sized for a warm relay pod (3 s acquire timeout,
two eager spare connections). In the first wave of the PostgreSQL lane
every nextest worker clones its database from the template at once, so a
first connection can take longer than 3 s.
- Fix: keep the production writer pool (`Db::new`, so the session policy
is still exercised) but set `min_connections` 0 and a 30 s acquire
timeout, the budget sqlx's `PgPool::connect` defaults give the other
PostgreSQL tests. `Db::connect_cold_start` uses the same values.
### Related issue
N/A (flake fixes; none found).
### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run these tests
themselves on this branch.
#### Verification
These changes were verified locally on the `flakey-monday` branch before
the split:
- buzz-cli throttle test: old code under CPU saturation (40 busy loops
on 32 cores) failed every iteration with `left: 1`, the CI failure. New
code, `desktop_throttle_tests` with `--test-threads=16`: 30/30 passed
unloaded, 15/15 under the same saturation.
- buzz-agent steer test: forced CI ordering (temporary 500 ms delay
before sending the steer) made the old test panic and the new test pass.
`fake_llm steer` tests with `--test-threads=16`: 30/30 passed. The
follow-up ran 100/100, and was falsified: with the empty-prompt guard
removed and the steer sent after the turn, the code check still passed
and the new message assertion failed.
- buzz-admin NIP-FI tests, against docker Postgres: the three tests, 10
iterations at `--test-threads=3`: 10/10. Behind a proxy delaying each
new connection by 4 s: the old config failed with the CI `PoolTimedOut`
at 3 s, the new config passed.
Note: `crates/buzz-cli/src/client.rs` also changed on `main` since the
series forked. The cherry-pick applied cleanly; only hunk offsets differ
from the original commit.
Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
d2c3234cb9 |
fix(ci): classify hermit retry timeouts by watchdog kill, not whole seconds (#9274)
## Summary Split out of the `flakey-monday` flake-fix series. `scripts/ci-hermit-retry.sh` decided an attempt had "timed out" by comparing whole-second elapsed time with the deadline, so an instant failure that straddled a second boundary was misreported as a timeout. The watchdog now reports through its exit status whether it actually killed the attempt. Commit (cherry-picked from `flakey-monday`, original message kept): - `fix(ci): report a hermit retry timeout only when the watchdog fired` ### Root cause and fix **node:test "persistent failure backs off between attempts and stops at the budget"** (`scripts/ci-hermit-retry.test.mjs`): expected "attempt 3/3 failed (exit 7)" and got "attempt 3/3 timed out after 1s". - Root cause (confirmed): the script checked `$((SECONDS - started)) -ge deadline`. Bash's `$SECONDS` counts whole wall-clock seconds, and the test uses a 1 s deadline (`CI_HERMIT_ATTEMPT_SECONDS=1`). An instant `exit 7` that crossed a second boundary looked like it had run for the whole deadline. - Fix: the watchdog subshell exits 0 if it is stopped before signalling the attempt, and 124 once it has sent TERM (or TERM then KILL). The script reports a timeout only when the watchdog exits 124; elapsed time is no longer used. Bash 3.2 (macOS) supports this. A new test starts 0.8 s into a wall-clock second and runs a 0.4 s attempt that exits 7, so it crosses a second boundary but finishes well inside its 1 s deadline, and asserts it is reported as "failed (exit 7)". ### Related issue N/A (flake fix; none found). ### Testing Human testing is still required. This PR does not carry `buzz-review-completed`; the author has not yet run the script or its tests themselves on this branch. #### Verification Verified locally on the `flakey-monday` branch before the split: - The new test against the old script: failed with "attempt 1/1 timed out after 1s", the CI symptom. - The whole `ci-hermit-retry.test.mjs` suite with the fix: 20/20 passed. Nothing was re-run on this split branch: the workstation was overloaded, so no tests, builds or linters were executed after cherry-picking. CI on this PR is the verification for the split branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: mtoohey <mtoohey@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ea548da808 |
test(ios): wait for a key, foreground-active window before presenting in RunnerTests (#9275)
## Summary Split out of the `flakey-monday` flake-fix series. Two iOS RunnerTests acted right after `makeKeyAndVisible()` without waiting for the window to become key in a foreground-active scene, which is the state the code under test resolves its presenter from. A shared test helper now waits for that state before the tests present anything. Commits (cherry-picked from `flakey-monday`, original messages kept): - `test(mobile): wait for a key window before presenting in iOS tests` - `test(mobile): wait for a foreground-active scene in the key-window helper` **These Swift changes were NOT compiled or run locally.** The fixes were written on a Linux workstation without Xcode or an iOS simulator, and this split branch was not built either. iOS CI on this PR is the first place they compile and run. ### Root cause and fix per test **`RunnerTests.testRejectedCommunityEntryPresentationCompletesAndAllowsRetry`** (`mobile/ios/RunnerTests/RunnerTests.swift`): timed out after 2 s waiting for "Temporary native modal appeared". **`IdentityFilePluginTests.testPickerResolvesPresenterAtRequestAndCancelsExactlyOnce`** (`mobile/ios/RunnerTests/IdentityFilePluginTests.swift`): waited 5 s for `parent.presentedViewController is UIDocumentPickerViewController`, which never became true. - Root cause (inferred from the code paths and CI symptoms; not reproduced): `NativeCommunityEntryCoordinator` and `IdentityFilePlugin.pick()` both resolve their presenter as the root of the `isKeyWindow` window in a `.foregroundActive` scene. `makeKeyAndVisible()` can return before key status moves to the new window, so `pick()` presented on a different window's controller and the expectation on `parent` could never be met. The community-entry test also attached its window to the first connected scene rather than the foreground-active one, swapped `rootViewController` and presented immediately; UIKit silently drops a presentation from a controller whose view is not yet in a window. - Fix: a shared `XCTestCase` helper in `RunnerTests.swift`, internal so `IdentityFilePluginTests` can use it without changing the Xcode project. `keyWindow(showing:)` attaches a window to the `.foregroundActive` scene, makes it key, and returns once a predicate expectation sees `window.isKeyWindow` and the root's view in that window. `keyWindowReady(_:showing:)` is that predicate; the community-entry test waits on it again after swapping the root. The follow-up commit replaces the `XCTUnwrap` of the foreground-active scene with the same predicate-expectation wait (5 s budget), since the first test to run could otherwise fail while its scene is still activating. The community-entry test became `async`; its fixed 2 s presentation wait became 5 s, the budget the picker test already uses. The readiness waits are the fix, not the longer timeout. Assertions are unchanged. ### Related issue N/A (flake fixes; none found). ### Testing Human testing is still required. This PR does not carry `buzz-review-completed`; the author has not yet run RunnerTests themselves on this branch. #### Verification Not run locally at any point: Swift tests need Xcode and an iOS simulator, which the authoring workstation lacked, and nothing was built on this split branch either (the workstation was overloaded, so no tests, builds or linters were executed after cherry-picking). The review pass on `flakey-monday` confirmed the `keyWindowReady` shape compiles; the follow-up follows that same shape. iOS CI on this PR is the verification for both the fix and the split branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mtoohey <mtoohey@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
15c2ca1b01 |
test(desktop): wait on real readiness signals in audience and community-rail specs (#9277)
## Summary
Split out of the `flakey-monday` flake-fix series. Three Desktop E2E
tests in `persistent-agent-audience.spec.ts` and
`community-rail.spec.ts` acted on signals that could arrive before the
app state they implied (a logged command before the submit lock
rendered, a URL check that only held before a view transition landed).
They now wait on the readiness signals the product actually exposes. No
product code changes.
Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(desktop): wait for composer focus and send cycles in audience
specs`
- `test(desktop): assert the kept remembered route after the community
switch`
- `test(desktop): assert Home after repairing the remembered channel`
### Root cause and fix per test
**`a manual mention persists when automatic mentions are enabled`**
(`desktop/tests/e2e/persistent-agent-audience.spec.ts`):
`locator.fill("follow up")` failed because `message-input` was not
editable.
- Root cause (from the code and CI symptom; not reproduced locally): the
test sends with a 1.5 s mock send delay, during which the composer is
submit-locked and `contenteditable="false"`. The lock is React state and
renders a frame after Enter. The test's last two checks before the fill
could both pass before that frame: the outgoing-mention poll (the e2e
bridge logs a command when invoked, before the delay) and the
`contenteditable="true"` wait. Playwright's `fill` rejects a
`contenteditable="false"` element outright instead of waiting.
- Fix: after Enter, wait for the full send cycle: `data-submit-locked`
"true", `contenteditable` "false", `data-submit-locked` "false",
`contenteditable` "true". Only then assert on the editor. Existing
assertions are unchanged.
**`the mention setting is reachable and operable by keyboard`** (same
spec): the thread composer's mention autocomplete never became visible.
- Root cause (likely; not reproduced locally): opening a thread by URL
mounts both the channel composer and the thread composer.
`useComposerAutofocus` focuses whichever mounts first and TipTap defers
`focus()` by a frame. A click and fill that land before that deferred
focus lose it, and the focus-gated mention menu unmounts.
- Fix: a `readyThreadComposerInput()` helper waits for the thread editor
to be editable and for exactly one `message-input` to hold focus, so the
app's own autofocus has landed. Both tests use it.
**`community rail > does not repair a remembered channel until live
validation succeeds`** (`desktop/tests/e2e/community-rail.spec.ts`):
`expect(page).not.toHaveURL(/#\/channels\/general$/)` failed right after
the click on community B.
- Root cause (confirmed; 21/40 failures at 14 workers on the old spec):
`switchCommunity` runs inside a view transition whose callback writes
B's remembered channel into the hash and then activates B, a few frames
after the click returns. By design the URL is then `#/channels/general`,
which `AppShell` keeps until a live channel read succeeds and only then
repairs to Home. The inverted check from
|
||
|
|
2b16d0ea1a |
feat(relay): let an operator mint invites for a community owner or admin (#9265)
## Summary
Adds `POST /operator/communities/invites`, so a deployment's trusted
operator (its account service) can mint an invite link on behalf of a
community owner or admin. The website where people manage their
communities never holds the owner's own key, so it can't call `POST
/api/invites` (owner-signed NIP-98).
- **Trust model:** the same as
`/operator/communities/{archive,transfer}`. A configured operator signs
the request (NIP-98, payload-bound, replay-checked) and vouches only for
**which identity** its signed-in caller is bound to (`actor_pubkey`).
The relay decides that identity's authority from **its own roster at
mint time**: owner or admin only, the same check `/api/invites` makes.
Nothing the operator says about roles is trusted.
- **Same invite:** it mints through `db.mint_relay_invite`, with the
same lifecycle gate and restriction check, the same
`ttl_secs`/`max_uses` bounds and defaults (`validate_mint_request`), and
the same response (`minted_invite_json`, extracted from `/api/invites`).
The link is an ordinary `https://<host>/invite/<code>` that every
client's join flow already claims.
- Archived, deleted and deleting communities return 404
(`lookup_community_host` only resolves active ones).
Request body (unknown fields rejected):
```json
{ "community_id": "<uuid>", "actor_pubkey": "<64-hex>", "ttl_secs": 259200, "max_uses": 5 }
```
Errors keep the `/api/invites` text in `error` and add a stable `code`,
so the operator can tell its own misconfiguration from the actor's
refusal without matching on text:
| Status | `code` | When |
|---|---|---|
| 403 | `not_operator` | the signer is not a configured operator |
| 400 | `invalid_request` | bad JSON, an unknown field, a bad pubkey, or
out-of-range limits |
| 404 | `community_not_found` | unknown, archived, deleted or deleting
community |
| 403 | `not_owner` | the actor isn't an owner or admin in the relay's
roster |
| 403 | `restricted` | the actor is banned or restricted
(`invite_restricted`) |
| 503 | `unavailable` | community writes are paused |
This is an HTTP operator route rather than an event kind because it
belongs on the existing operator plane (operator-signed, outside the
event data plane), like the other operator community actions.
### Stack (invite links on the Builderlab Communities page)
1. **This PR**: the relay route. It ships first.
2. The account service endpoint that calls it (private repo). Depends on
this PR.
3. The website UI (private repo). Depends on 2.
### Related issue
None found.
### Testing
- New Postgres journey
`operator_mints_invites_only_for_relay_owners_and_admins` (run against a
freshly migrated local database):
- An owner mints with the defaults. The URL, null `max_uses` and the 72h
expiry match `/api/invites`.
- A joiner **claims the minted code through `POST /api/invites/claim`**
and becomes a member.
- An admin mints with limits.
- A banned admin is refused.
- A member and an outsider get 403 with the same refusal text as
`/api/invites`. The test pins each error's `code`.
- Out-of-range `ttl_secs` and `max_uses`, an unknown field, a bad pubkey
and a non-operator signer are all refused.
- An unknown community and an archived community get 404.
- `cargo test -p buzz-relay --lib -- --include-ignored api::invites
api::operator`: 78 passed and 2 timed out under heavy machine load. Both
timeouts (`claim_follow_up_survives_a_client_disconnect`,
`temporary_listing_failure_is_recovered_for_each_admission_path`) also
pass on this branch when run on their own, as on `main`.
- `cargo fmt` and pre-push hooks (clippy) passed.
- **Not yet done:** a run against a local relay with `curl`, and a human
check.
---------
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz>
Co-authored-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
66095e5598 |
fix: inline ARM64 atomics in Sprig image build (#9291)
## Summary Sprig's ARM64 container fails to link because bundled D-Bus emits a call to GCC's `__aarch64_ldadd4_sync` helper, which Rust's static musl link does not supply. AMD64 succeeds, but the failed ARM64 job blocks publication of the multi-architecture image. Set the ARM64 musl C compiler flags to `-mno-outline-atomics` in the image builder. This emits baseline ARM64 atomic instructions inline instead of requiring the missing helper. Other targets and the runtime image configuration are unchanged. ### Related issue No duplicate fix found. [Failing build](https://github.com/block/buzz/actions/runs/42667527024/job/129335071943). The [previous main build](https://github.com/block/buzz/actions/runs/42642040718) has the same failure, before the MCP dependency update. ### Testing The exact local ARM64 image build passed: `docker build --platform linux/arm64 --file Dockerfile.sprig --tag buzz-sprig:arm64-link-fix .`. The resulting `sprig --version` command passed. The `buzz-agent` personality reached its expected missing-provider configuration error. Broad local checks were not run. Generated with Codex Signed-off-by: Salman Mohammed <smohammed@squareup.com> |
||
|
|
49511f02b6 |
fix(mobile): show observed thread replies in Activity (#9286)
## Summary A reply in a thread you started, participated in, or followed can light a mobile channel's attention dot without appearing in Activity. Activity now includes eligible replies already observed by the channel unread pipeline in **All** and **Threads**. Tapping a row uses the existing oldest-unread reply and thread navigation. Retain the reply payload alongside its bounded unread evidence, then merge by event ID with the addressed feed. Mentions retain their category and do not duplicate. Deletion, eviction, and account/community resets share the same registry. This adds no relay queries or subscriptions. Coverage follows the existing channel unread catch-up and live subscriptions. This does not introduce complete historical thread discovery: replies outside that retained window, and replies not eligible for the channel attention dot, are not newly fetched. ### Related issue Related to #9271 (clearing channel rows) and closed #9186 (broader Activity feed expansion). #9213 supplied the existing Activity filters. This PR connects existing observed replies to those filters. ### Testing - Full mobile suite, push-unconfigured tests, analysis, formatting and file-size checks. - Production-provider regression: cold catch-up, live reply, mention deduplication, excluded unrelated/quiet replies, thread read markers, deletion/replay and identity reset. - Real Activity widget journey with fake relay/storage boundaries: show the unmentioned reply, select Threads, tap the row, and verify the reply ID and thread ID passed to the conversation page. - Mutation check: removing reply-payload retention makes the widget journey fail because the reply is absent. - Independent agent source review: no blockers. ### Preview Actual Flutter widgets at phone width with synthetic data and the new reply wiring disabled/enabled. These are widget-test captures, not installed-device or live-relay evidence. | Before | After | | --- | --- | |  |  | ### Human validation pending Draft per the repository readiness checklist. On a device, receive an unmentioned reply in a thread you started, replied in, or followed. Check Activity → All and Threads, tap the row to reach the reply, then read it and confirm Unread clears without reading unrelated channel messages. Native/live-relay testing and human confirmation 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: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2bbaa061d0 |
Enable mobile huddles with Codex agents (#9269)
Show the Huddle action in owned Codex agent DMs and invite the agent before connecting audio. Revalidate eligibility on starts/recovery, ask before inviting into an existing call, and distinguish pending invitations from connected audio peers with actionable timeout feedback. DM hangup leaves shared room cleanup to the relay so another owner device stays connected. Pairs with https://github.com/block/buzz-app/pull/3048. Desktop and the Codex agent must remain running. Validated: 4,559 mobile tests, analysis, formatting, push hooks, and independent source review. Live phone-to-desktop audio and human trial 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: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c1ce2ca800 |
fix(mobile): read mentions taller than the screen (#9243)
## Problem Bug report (mobile 0.28.0 (46)): opening a mention from the Inbox in an archived Solo chat leaves the Inbox row unread. The video shows the cause. Each unread row is a long agent answer. On a phone, the answer is taller than the screen, or it lands partly cut off after the deep-link jump. Mobile automatic reading writes a `msg:` mark only for a row that is fully visible for the 300 ms dwell. A mention row is high priority, so `activity:<channel>` does not read it. The mention can stay unread until the reader marks it read by hand. The archived state is not the cause. A widget test shows that an archived Solo chat writes read marks for short messages. Archived chats make the problem easy to see, because there is no composer and the reader only reads. ## Fix A message counts as read only when it actually showed, for the 300 ms dwell. A row counts when any of these holds: - **It fits:** the whole row is inside the reading area (unchanged). - **It fills the screen:** the row is taller than the reading area and covers all of it. This applies to the channel timeline and the thread view. - **A link opened it:** the message a deep link or Inbox tap opened counts once any part of it shows, because the jump places it at 35% and a long one lands cut off. - **It is the newest message at the live bottom:** its end is showing, in a channel, a Solo chat (replies inline) or a thread. This covers a long final answer that only ever shows its end. Reaching the bottom does **not** read mentions or broadcasts above the newest message that never came on screen. Desktop does that sweep (block/buzz-app#1679). Mobile deliberately does not. Ordinary `activity:` / `thread-activity:` catch-up is unchanged, and messages the reader marked unread stay unread. ## Tests - `reading_marks_test.dart`: the bottom does not read mentions above it; the newest message is read at the live bottom (and respects manual unread); a flat Solo timeline reads only its newest inline reply; a tall thread tail reply is read at its end; DM catch-up does not mark mentions; `isReadableRow` cases, including linked rows. - `channel_detail_page_test.dart`: an archived Solo chat and a Solo inline reply each read a tall newest mention at the bottom; a tall row away from the bottom is read once it fills the screen; tall linked messages (channel and thread) are read when the jump leaves them cut off; in a thread, the tall last reply is read at the end and an earlier tall reply is read once it fills the screen. These fail on `main`. - `just mobile-check` and the full mobile suite pass. ## Human test steps 1. Get a long agent reply that mentions you in a Solo chat, then archive the chat. 2. Open the row from the Inbox and hold still for about one second. 3. Go back. The row should now show as read, with no dot. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Blake McAnally <bmcanally@squareup.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: BlakeBot <85bbd70443139fd5c5762e34c2e214240939eb243c37f98380f69fed3b3bb4a6@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz> |
||
|
|
b082b1464c |
test(relay): give every media e2e upload its own blob hash (#9272)
## Summary Split out of the `flakey-monday` flake-fix series. Two Relay E2E media tests shared one deterministic blob hash across every test in their binaries. In enforce mode (`BUZZ_MEDIA_READ_MODE=enforce`, on for the Relay E2E lane since #9077), a rejected publish of that shared hash left a sticky publication marker that made ranged GETs from the other tests return 404. Every media e2e upload now has its own blob hash. Commits (cherry-picked from `flakey-monday`, original messages kept): - `test(media): give each video e2e test its own MP4 blob` - `test(media): publish images of their own in the media e2e tests` ### Root cause and fix per test **`test_video_range_request_206` / `test_video_range_request_416`** (`crates/buzz-test-client/tests/e2e_media_video.rs`): a ranged GET returned 404 instead of 206/416 in 9 of 55 Relay E2E jobs. - Root cause (confirmed): `build_test_mp4()` was deterministic, so every test in the binary uploaded the same sha256, and the tests run in parallel against one relay. In enforce mode, `validate_media_references` records a sticky `media_publications` marker for every hash an event publishes, before dispatch, so a later rejection does not undo it. `test_video_poster_imeta_rejects_video_as_poster` published the shared hash and was then rejected by imeta validation. From then on `media_access` gave an uploader with no readable reference `Restricted`, served as 404, until the accepted poster test stored its event in an open channel. - Fix: `build_test_mp4()` appends a top-level `free` box holding a random UUID, so each call yields a distinct, still valid fast-start MP4. The upload validator allows `free` and does not inspect its payload. The relay is unchanged: the pre-dispatch marker is the documented fail-closed contract in `docs/operator-private-media.md`. **`test_video_poster_imeta_accepted_via_ws`** and **`e2e_media_extended::test_ws_valid_imeta`**: hardening, not a fix for an observed failure. Both published a fixed 1x1 JPEG byte-identical to `tiny_jpeg()` in `e2e_media.rs`, which `test_upload_and_get` reads back through an uploader preview. That currently passes (a readable live reference wins before the marker matters), but a publishing test that shares a fixed blob with other media tests is one rejected or private event away from the same flake. Both now publish an 8x8 PNG of random pixels from a new `unique_png()` helper; `test_ws_valid_imeta`'s imeta declares `image/png` and the real size; `tiny_jpeg()` is removed from `e2e_media_video.rs`. ### Related issue N/A (flake fixes; none found). ### Testing Human testing is still required. This PR does not carry `buzz-review-completed`; the author has not yet run these tests themselves on this branch. #### Verification These changes were verified locally on the `flakey-monday` branch before the split, against an enforce-mode relay with docker Postgres/MinIO: - Old code, fresh enforce-mode relay: reject test then range tests failed both range tests with 404 at the CI lines (reproduction). - Whole `e2e_media_video` binary, `--test-threads=8`: 20/20 passed. - Reject test then range tests against the polluted hash: 3/3 passed. - The CI "Media read-auth e2e" command (`e2e_media`, `e2e_media_extended`, `e2e_media_video`, `--ignored`), 5 back-to-back runs on the same scratch database (the persistent-relay case): 5/5 runs, 40/40 tests each time. - `cargo fmt --check` and `clippy --tests`: clean. Nothing was re-run on this split branch: the workstation was overloaded, so no tests, builds or linters were executed after cherry-picking. CI on this PR is the verification for the split branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mtoohey <mtoohey@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
838976d6e4 |
Add invite preview video and fallback poster (#9282)
Adds a 16-second Buzz invite-preview video and wordmark-only poster, matching onboarding colors and fuzzy texture. Includes a local preview/poster picker and reproducible media export. This prepares assets; live invite Open Graph metadata and relay deployment are follow-up work. Validated: preview browser checks and independent review pass. Full `just ci` stopped during Rust compilation because the disk filled. Native iMessage playback is not yet tested. Video: https://github.com/user-attachments/assets/8977f990-fb79-4217-bec1-fa8d2b93ece0 Fallback poster:  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen <baxen@squareup.com> |
||
|
|
3071279cd8 |
NEXUS-4336: Wait for desktop-provider socket ownership proof (#9270)
## TL;DR Retry missing desktop-provider socket ownership proof on the same open connection, within the existing five-second limit. Credentials and provider requests remain withheld until the exact server-side socket belongs to the caller's UID; a wrong UID or lookup error still fails immediately. The ownership code moves into its own module to keep the existing oversized file within the repository's file-size ratchet. The macOS lookup now bounds output and checks the child exit status while retaining cancellation cleanup. ### Related issue Fixes NEXUS-4336. ### Testing 42 provider tests passed on Linux, including synthetic transport tests for delayed/absent proof, wrong UID, lookup failure, deadline expiry, zero early request bytes, and child cleanup after timeout/cancellation. Targeted Clippy, formatting, and file-size checks passed. Removing the retry or bypassing proof caused the production-path regression to fail. Native macOS execution, installed/source parity, and the cause of any live authentication failure remain unverified. This source change does not establish installed or live recovery. No human-test attestation is claimed. Generated with Codex. Posted by Codex (gpt-6.1-sol) on behalf of kylez. Signed-off-by: Kyle Zemek <kylez@squareup.com> Co-authored-by: Codex <noreply@openai.com> |
||
|
|
658253009c |
fix: unblock image audit with patched MCP SDK (#9287)
## Summary The latest main image builds succeed on both architectures, but publication fails because the same-SHA CI security audit rejects newly reported advisories. Update rmcp 3.4.1 to 3.9.0 to fix its HTTP client, server-cache, and OAuth advisories. Add documented exceptions for rmcp 1.8's disabled HTTP transports and SQLite used only by the examples-only MeshLLM smoke harness's private log store. Dependency and feature bans constrain those exceptions. This does not patch the remaining SQLite version or migrate buzz-agent's subprocess client. ### Related issue None found. Failing runs: [CI](https://github.com/block/buzz/actions/runs/42653097086), [image publication](https://github.com/block/buzz/actions/runs/42653096928). ### Testing No manual runtime testing. Local validation was limited to the dependency audit, buzz-dev-mcp compilation, SQLx pin verification, and lightweight Git hooks. Broad local Rust and Tauri hooks were skipped at the author's request. The image qualification gate remains intact. Generated with Codex Signed-off-by: Salman Mohammed <smohammed@squareup.com> |
||
|
|
34bab2f64b |
feat(ci): allow admin dispatch of desktop candidate validation (#9288)
## Summary Desktop Release Candidate only supports PR events today. Add a manual dispatch with a required version input that validates the existing `version-bump/<version>` branch. Require the workflow from `main` and current repository admin permission for both the original actor and any rerunner before checkout; API failures deny the run. Existing PR validation and read-only token permissions are preserved. GitHub can still accept a dispatch from a writer; the workflow rejects that run before candidate checkout. This validates an existing candidate and does not create or publish a release. Document the command in `RELEASING.md`. ### Related issue None found in open issue/PR title searches for "desktop release candidate". ### Testing - `actionlint .github/workflows/desktop-release-candidate.yml` passed. - `bash scripts/test-desktop-release-candidate.sh` passed, including the new authorization tests executed from the production workflow shell with only the GitHub API mocked. Covers admin access, all lesser roles, missing permission responses, API failures, rerunners, and non-main dispatches. - Mutation checks confirmed that removing the admin check, rerunner check, or main-ref restriction fails the new tests. - Self-review completed with no blocking findings. - `just ci` is running locally; results will be updated here. - Hosted manual dispatch has not been exercised: the new guard requires dispatch from `main`, where this change is not merged. Human verification is pending, so this PR remains a draft under `AGENTS.md`. Once available on `main`, an admin can test with `gh workflow run desktop-release-candidate.yml --repo block/buzz --ref main -f version=<existing-candidate-version>`. Expect permission checks to pass and the immutable candidate validator to run against that candidate. A writer or a dispatch selecting another workflow branch should fail before checkout. Manual runs do not replace the candidate PR's required checks. --------- Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> Co-authored-by: BigBoss <19a7c9f8477c0dd12d428c2b2faa352556cd13b3ec3d717498b60f85a57a90a9@buzz.block.builderlab.xyz> |
||
|
|
c502e53e63 |
feat(mobile): tag the reacted-to message author on reactions (#9141)
## Why NIP-25 says a reaction should carry a `p` tag naming the author of the reacted-to event. buzz-app's Activity → Reactions tab (block/buzz-app#2638, merged) finds reactions to your messages with a `#p` read. Reactions sent from mobile have no `p` tag, so they never appear there. block/buzz#9121 makes the same change for `buzz reactions add`. ## What - Every reaction sent from mobile now carries `["p", <message author>]`. This covers the reaction pill, the quick-reaction row, the emoji picker, and reactions on system rows. - Removing a reaction is unchanged. - Agent status reactions (buzz-acp) and the desktop app are unchanged by decision. Agent status reactions stay out of Activity. ## Testing - New unit test: `ChannelActions.addReaction` publishes exactly `["e", target]` and `["p", author]`. It fails without the change (actual tags: `[['e', 'target-event']]`). - The message-actions widget test now checks that a quick reaction passes the message's author. - `just mobile-check`: format and `flutter analyze` are clean (no issues). - `just mobile-test`: 4,507 passed, plus the push-unconfigured and secp256k1 suites. All pass. - No screenshots: the change is one tag on the published event, and nothing on screen changes. - Not yet: a run on a device, and the human test. ## How a human can test 1. On mobile, as user B, react 👍 to a message from user A. 2. As user A, open Activity → Reactions in a current buzz-app build. 3. It works if a "B reacted 👍" row appears. Before this change, no row appears. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matthew Boston <mboston@squareup.com> Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>mobile-v0.28.0-rc.10 |
||
|
|
5f59940544 |
fix(mobile): steady New DM picker rows and animate picks (#9249)
On iOS, the New DM (and Add Members) picker is a native UIKit sheet that Flutter drives by pushing the picker's whole state on every rebuild. Each push called `reloadData`, which rebuilt every visible row in a fresh, unreused cell showing the initials fallback, then fetched each avatar again over the platform channel. So whenever anything refreshed (several refreshes land right after foregrounding), every avatar blinked to its initial for a frame or two. Picks also jumped instantly from the results to the chosen section. Now the picker: - ignores a pushed state identical to the one it shows; - applies the rest as a diffable snapshot, so unchanged rows keep their cells and changed rows are redrawn in place, in reused cells; - keeps loaded avatars by avatar key for the life of the sheet, so a redrawn row shows its avatar at once; - animates a member's row moving between the chosen and result sections when it's picked or removed (no animation with Reduce Motion). Flutter's toggle handlers in both pickers now read the current selection rather than the one captured at build time. A fast second tap could otherwise add the same person twice, which the diffed table would reject. ### Validation - Native member picker test (`scripts/test-mobile-member-picker.sh`), extended: after an avatar loads, a changed row is redrawn with that avatar and requests nothing more. The avatar check fails on `main` and passes here; the existing identity and accessibility checks still pass with the diffable table. - Added a New DM test: two toggles before Flutter rebuilds never list a person twice. It fails without the toggle fix. - Full mobile Flutter suite and analyzer pass in CI on the current head, which includes `main`'s fix for the right-drag link tests. - Simulator runs of the app (iPhone 17 Pro, iOS 27, signed in to a test community): opened New DM, backgrounded and foregrounded the app, searched, picked from the list and from a search, and removed a pick. Without this change, every avatar flashed to its initial after a foreground. With it, no avatar flashed at any point, and picks and removals animate between sections. Recordings show real community members, so they're shared in Buzz rather than here. #### Full-app comparison, `main` vs this PR The same scripted New DM sequence on a clone of the same signed-in simulator (iPhone 17 Pro, iOS 27), first on `main` ( |
||
|
|
52cb6818fa |
perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary The mobile app now saves remote images on disk. After a relaunch, avatars, custom emoji, message attachments and agent artwork show immediately. The app does not download them again. Small images on a relay now load only the relay's thumbnail. The app never downloads the full-size upload to show a small image. ## Why Before this change, the app kept remote images only in memory. Each cold launch downloaded every image again. On a slow connection, attachments showed a "Loading image" placeholder for a long time. Also, when a relay had no thumbnail for an image, the app downloaded the full-size upload. That upload can be up to 20 MiB, for an avatar that is a few dozen points wide. ## What changes ### Images on disk - `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account in each community has its own cache, at `<root>/communities/<community id>/media/<signer pubkey>`. The cache is built on `flutter_cache_manager`, with a JSON index. - On iOS, the root is the app group's `Library/Caches` (from a new `buzz/app_group` method channel). App extensions can then read the same cache. On other platforms, the root is the app cache directory. - Each cache holds a maximum of 250 MB. When it is full, the least recently used images go first. `MediaCacheIndex` measures each file when it writes it, because `flutter_cache_manager` limits only the number of files. Every disk read marks the file used, and every write schedules eviction, including a refresh that replaces bytes the package already holds in memory (by default the package does neither for those). - The cache does not save an image that is larger than 5 MiB. The app still shows that image from memory. Thus, one large image cannot push out all the others. - Relay media URLs contain a content hash, so their bytes never change. The cache keeps them until it evicts them. For other URLs, the cache shows the saved image, and then gets a new copy in the background after one week. A maximum of two of these refreshes run at the same time. The app decodes a refreshed image before it replaces the saved copy. - When you remove a community, the app deletes its cache. When a community gets a new signing key, the app deletes the media of the old account. At launch, the app also deletes caches of communities and accounts that are no longer present. - Startup does not wait for the cache. The cache directory resolves in the background. Image reads wait for it for a maximum of 3 seconds. ### Thumbnails for small images - A small relay image loads the relay's WebP thumbnail. If the relay has no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has neither, the widget shows its placeholder (initials for an avatar, an empty tile for artwork). - Native avatars drawn by UIKit (the navigation bar, the community icon on the create button, and the New DM and Add Members pickers) use the same choice, through a new `smallMediaImageProviders`. They try the same thumbnails, fall back the same way, and show initials when neither thumbnail exists. Before, they downloaded the full-size upload and only decoded it small. - Larger layouts and the full-screen viewer load the full-size upload, as before. ### Agent artwork - Preset agent artwork (the tray, flight, video placeholder, avatar hero and the avatar editor's blurred backdrop) now loads through `MediaImage`, so it uses the disk cache too. - The create-agent and empty-bestie pages warm the same `MediaImageProvider` that their widgets read, through a new `mediaImageProviderFor(ref, url)`. - No code in `mobile/lib` uses `Image.network` or `NetworkImage` now. ## Demo A cold relaunch (the app is stopped, then started) into a synthetic "QA demo" Solo chat that holds a few generated images. Shown at half speed. Before: the attachment shows a "Loading image" placeholder while it downloads. https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97 After: the attachment shows with the chat, from disk. https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d ## Tests - `test/shared/relay/media_disk_cache_test.dart` follows one journey through the real provider and a real cache in a temporary directory. The first launch downloads and saves. An offline relaunch shows the full-size image, its thumbnail and an external image from disk, and the oversized image goes back to the network. Eight days later, only the external image refreshes. Removing the community deletes its directory. - A second test checks the order of eviction when the cache is full, that a file read from disk is evicted after files that were not, and that replacing a file's bytes after eviction last ran evicts again when the cache goes over budget. - `community_provider_test` checks that launch deletes orphan media directories, that a new signing key deletes only the old account's media, and that removing a community deletes its media. - `avatar_image_test` checks that a missing WebP thumbnail falls back to the JPEG thumbnail, and that an avatar with no thumbnail shows its initials. - `native_avatar_image_test` checks the same for native avatars: a WebP 404 paints the JPEG thumbnail, the full-size upload is never requested, a relay with no thumbnail paints initials and is not asked again, and any other failure stops at the initials. - `flutter analyze` and the full mobile test suite pass. - Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel list, a DM, Home, the Agents grid and an agent details sheet show their images. After a relaunch, the images came from disk and the app wrote no new files. In the "QA demo" chat, a new image from the app showed immediately. - Automated run of the native-thumbnail fix on an iPhone 17 Pro simulator (iOS 27): the native New DM picker shows photo avatars, and the cache gained only relay `.thumb.webp` entries for them, no full-size uploads. After a cold relaunch, the picker showed the same avatars within about 1.5 seconds, and the cache directory gained no files. The avatar editor backdrop was not exercised in the app. The test community has no remote agents, and the backdrop tests cover only emoji and picked-image selections. ## Before merge - [x] Automated simulator run of this head: avatars and agent artwork show their thumbnails, and a cold relaunch shows images from disk. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
15174bc2b2 | fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) | ||
|
|
f38285562c |
fix(mobile): read only shown events from filtered Activity rows (#9215)
## Summary Follow-up to [#9213 Activity filters UI](https://github.com/block/buzz/pull/9213), from its [last Codex finding](https://github.com/block/buzz/pull/9213#discussion_r4497322772). With Threads, Mentions or a People/Agents filter active, an Activity row holds only part of its conversation. Marking it read advanced the `thread:<root>` (or channel) marker through the row's latest event, which also read replies the filter hid. For example, reading a person's reply in People also read an older agent reply in the same thread. Filtered rows now advance the synced thread or channel marker through every event that is read, stopping just before the first unread event the filter hides, and write local `msg:` marks only for shown events past that point. Once the hidden event is read too, the marker catches up and syncs both. This also fixes filtered DM rows from #9213, whose `msg:`-only reads never synced to other devices. Unfiltered rows are unchanged. ## Testing - New widget test: "reading a thread row under People leaves hidden agent replies unread". It fails without the fix, and checks the synced marker stops short of a hidden unread reply, then advances fully once nothing hidden is unread. - `flutter test test/features/activity/` (136 pass) and `flutter analyze` on the activity feature. ## Before merge - [x] Automated simulator run (iPhone 17 Pro sim, iOS 27, re-run on d17f79a; the catch-up change in |
||
|
|
58696ff577 |
fix(mobile): show the channel name as the notification subtitle (#9173)
On Apple Watch, a Buzz notification showed the community name ("buzz")
under the sender, taking a line of the small display without saying
where the message came from. iPhone never showed it, because iOS draws
the banner from the communication intent and drops `content.subtitle`.
The Watch renders the plain fields.
The subtitle now carries the conversation name, the same one the intent
uses for its group name:
- Team channels and forums: `#channel`
- Your own Solo chats: the bare name, no `#`
- DMs, and channels not yet in the presentation cache: no subtitle
The name logic moves into one helper so the subtitle and the iPhone
group name can't drift apart. A channel that's known but isn't eligible
for the communication style (for example, an unverified roster) now gets
`#channel` as its plain-banner subtitle in place of the community name.
Losing the community name means the Watch no longer tells communities
apart. That seems like the right trade for most people, who are in one
community.
### Validation
- BuzzPushKit `swift test`: all 110 XCTest and 69 Swift Testing cases
pass. Updated the resolver assertions: `#General` for a cached stream,
both with and without communication styling, and no subtitle for DMs and
uncached channels.
- Not yet exercised on a device or Watch.
🤖 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>
|
||
|
|
e1e1cd50ab |
fix(mobile): keep Home's header clean under sheets (#9152)
On iOS, opening a sheet over Home (for example a channel's long-press actions) left a hard horizontal edge and a light glow band under the filter chips. Home's header keeps a native UIKit soft-edge view behind its Flutter controls, and the sheet's dim barrier doesn't composite cleanly over it. While another route covers Home, the header now switches to its Flutter backdrop through the existing `FrostedAppBar.nativeViewSuppressed` hook, the same mechanism other headers use while a backdrop covers them. Home already hides its native bottom controls under the same condition. | Before | After | | --- | --- | | <img alt="before" src="https://github.com/user-attachments/assets/a5bf01da-4f88-4055-809d-e347b35408a9" width="300"> | <img alt="after" src="https://github.com/user-attachments/assets/6813bb27-9f76-45d5-81c3-b46d8e0df647" width="300"> | ### Validation - iPhone 17 Pro simulator (iOS 27), Builderlab test community: reproduced the artifact on `main` with Home scrolled and the channel actions sheet open, then confirmed it is gone with this change. Also opened and dismissed the sheet repeatedly and checked the header before, during and after. - Transition timing, frame-stepped from simulator recordings of `main` (top row of each strip) and this PR (bottom row) on the same Home state: - The header and the bottom navigation controls switch on the same frame. A modal sheet doesn't drive Home's secondary animation (`MaterialPageRoute.canTransitionTo` is false for a sheet route), so the bottom controls' `secondaryAnimation == 0` check reduces to the same `isCurrent` gate the header uses. - Known limitation: while the native header view is swapped out, the header blanks for about 2 display frames as the sheet opens and about 3 as it closes (about 30 to 50 ms, seen as the missing chips below). `main` has no blank frames but shows the hard edge. Opening: <img alt="sheet open, main above PR" src="https://github.com/user-attachments/assets/df8f04f2-da72-4889-b9c8-5c47596fe7e3" width="900"> Closing: <img alt="sheet close, main above PR" src="https://github.com/user-attachments/assets/aa234a82-773d-4c9f-bba4-469afd158de8" width="900"> - Extended the iOS Home widget journey: the native header view is removed while the channel actions sheet is open and restored after it is dismissed. Without the fix, the journey fails. - Full mobile Flutter suite passes. 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> |
||
|
|
ac2c5831f3 |
fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150)
On iOS, the reactions sheet (long-press a reaction pill) could show a custom emoji as truncated `:shortcode:` text, for example `:eye…`, even though the same emoji rendered as an image on the message pill a moment before. The sheet is a native UIKit view that downloaded each custom emoji again with its own `URLSession` request and showed the shortcode label until that download finished, and for good when it failed. A shortcode is illegible in a chip that size. Now: - Flutter keeps the encoded bytes of custom emoji it has downloaded (a bounded LRU: 512 KiB per emoji, 8 MiB total, rebuilt when the media auth scope changes so accounts never share bytes) and hands them to the sheet, which decodes them without a fetch. Emoji the timeline never loaded still download natively as before. - A custom emoji in the sheet never shows its shortcode: it stays blank while loading and shows the sparkles icon, matching the native emoji picker, if the image cannot load. Reaction pills in Flutter get the same sparkles fallback. Inline emoji in message text keep the shortcode fallback, where it reads as text. Avatars in the sheet keep their initial. ### Before / after Reactions sheet header and chips right after opening, on a message with two custom-emoji reactions (synthetic test channel, captured from a 30 fps screen recording). | Before (main) | After (this branch) | | --- | --- | | <img width="300" alt="Before: custom emoji chips show truncated :qa-... shortcodes" src="https://github.com/user-attachments/assets/593e555b-9c82-4eec-9049-3d909f987c76"> | <img width="300" alt="After: custom emoji chips show images" src="https://github.com/user-attachments/assets/a6739997-b39f-4062-9e5c-d22a8a38b368"> | ### Validation - Added tests: the native sheet's payload carries the bytes the pill already downloaded; the combined reaction tray shows sparkles, not the shortcode, when a custom emoji fails to load (the existing alignment test previously asserted the shortcode). - Full mobile suite and `just mobile-check` pass; the iOS simulator build compiles. - Automated simulator run (iPhone 17 Pro, iOS 27, main vs this branch, signed in to a test community): channel list, opening a channel, the message action sheet, the emoji picker, and the reactions sheet on a message carrying two custom emoji I added plus a thumbs-up. On main the sheet's custom-emoji chips show truncated `:qa-...` shortcodes for a few frames before the images arrive; on this branch they render as images from the first frame (see Before / after). Limitations: the emoji images load fine over the network in this setup, so the failed-download sparkles fallback was covered by tests only, not seen in the app; a long press on the combined reaction tray opened the message menu in the simulator, a tap opened the sheet. 🤖 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> |
||
|
|
40cfa72cd4 |
mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.
The row sheet now also offers:
- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.
The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).
### Demo
Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.
https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3
### Validation
- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (
|
||
|
|
ad5699d05a |
fix(mobile): start no threads in Solo chats (#9123)
Agents in a Solo chat always answer at the chat's top level. On mobile you can still start a thread in Solo (Reply action, swipe-to-reply, media reply) or reply inside an existing one, so the agent's answer lands outside the thread you wrote in. Desktop already avoids this: Solo shows existing threads but starts none and has no thread composer. This matches desktop on mobile: - No Reply quick action, swipe-to-reply or media reply in Solo chats. - Existing Solo threads still open from their reply summary, read-only (no composer). - Every other channel is unchanged. ### Demo Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right swipe on an agent message, then long-press for the actions. Before (main): the swipe shows the reply circle and opens a thread with a composer; Reply is in the actions. https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906 After (this branch): the swipe does nothing; the actions have no Reply. https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e ### Validation Added tests: a Solo chat's message neither swipes to reply nor offers Reply in its actions, and a Solo thread opens without a composer. Existing swipe-reply and Solo channel tests pass, along with the full mobile suite and analyzer. Simulator run on an iPhone 17 Pro, signed in to a real community: in an existing Solo chat, a right swipe on an agent message no longer moves or opens a thread, and the long-press actions show no Reply (Copy link, Remind me and the rest are unchanged). Team-channel Reply is covered by the existing swipe-reply tests, not this run. A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat, compared main and this branch: on main the swipe opens a thread with a composer and the actions list Reply; on this branch neither does (see Demo). The Demo's "Follow thread" action still appears in a Solo chat's actions on both builds; this PR does not change 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> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
733d164e80 |
mobile: persist user profiles so names and avatars render on launch (#9116)
On a slow connection, mobile shows npubs and blank avatars on every cold
launch and community switch, because user profiles live only in memory
and are refetched from the relay each time.
This persists the verified profiles the app has already seen, per
identity and relay, so names and avatar URLs render on launch before the
relay answers.
### Behavior
- Profiles are stored as a disposable cache in the same shape as the
existing channel list snapshot: one SharedPreferences entry per identity
and relay, versioned, capped at 2000 profiles and 2 MB (oversized inline
avatars are skipped), deleted on sign-out and by orphan cleanup.
- On launch the cache restores the snapshot synchronously. Each restored
profile is still revalidated against the relay on its first lookup that
session. `get` and `preload` return the restored data right away instead
of waiting on the network, and a failed revalidation retries on the next
lookup.
- Identity-sensitive callers keep their guarantees: `resolve()` and
`refresh()` treat restored profiles as unconfirmed and query the relay,
including while a background revalidation is still in flight.
- Writes are debounced (2 s) and flushed when the community scope
changes.
Not in this PR: avatar image bytes still use only Flutter's in-memory
image cache, so avatar images still download after a cold launch (names
and URLs no longer wait). The NSE keeps its own app-group profile cache,
which this doesn't share yet.
### Demo
Cold relaunch into a synthetic Solo chat ("QA demo"), iPhone 17 Pro
simulator, same clip timing on both builds. To make the gap visible on a
fast connection, both builds had a local-only 5 s delay on profile
fetches (not part of this PR). The channel's system lines show the
member name once its profile is known and a shortened npub until then.
Before (main): "npub1...created this channel" for about 5 s until the
relay answers, and the agent avatar starts as a letter placeholder.
https://github.com/user-attachments/assets/de558521-a210-4ac6-b216-d77260036985
After (this branch): "tomb created this channel" and the agent avatar
render from the first frame.
https://github.com/user-attachments/assets/093b30fc-ec24-4f26-8535-7cc1791d2e44
### Validation
- Added tests: a journey across three simulated launches (relay
reachable, offline, reachable with a rename) covering restore,
non-blocking revalidation, retry after failure, a concurrent `resolve()`
during revalidation, and sign-out removal; plus storage scoping, bounds,
writer retirement and corrupt-data handling. The revalidation and
in-flight guards were each removed once to confirm the journey fails.
- Full mobile check and test suite pass locally.
- iOS simulator (iPhone 17 Pro, Remote Agents Pilot as Tom's Builderlab
account): after browsing, the snapshot held 16 verified profiles (6.4
KB), and cold relaunch rendered normally. Offline cold launch was not
exercised on the simulator.
- iOS simulator, synthetic Solo chat, main vs this branch (see Demo):
with profile fetches artificially delayed 5 s, main showed shortened
npubs in the channel's system lines until the relay answered, and this
branch showed the restored names on the first frame.
🤖 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>
|
||
|
|
439d7bcc12 |
fix(mobile): keep a sent thread reply above the composer (#9026)
Fixes two thread symptoms that come from the same state: after sending a reply, the composer clears but the new reply sits hidden under the composer (or out of view entirely) until you leave and return, and the **Latest** button stays visible even though the list looks like it is at the bottom. ### Cause 1. A sent reply grows into place over its 500 ms entrance transition (`LocalSendRow`). The thread's one-shot tail correction can finish while the row is still growing. Once it has finished, nothing moves the list as the reply keeps growing, so the list stops short of the real end (about 40 pt on an iPhone 17 Pro simulator) and the reply ends up behind the composer. Because the list isn't actually at its end, **Latest** shows. It is visible even with the composer focused when an earlier drag detached tail-follow. 2. A thread opened at a specific reply (from Inbox, a push notification or a message link) skipped the new-reply follow logic for its whole lifetime, not just for the replies it opened with. If the reader scrolled at all before replying, their own sent reply was never brought into view: it landed below the viewport, and a quick swipe couldn't reach it before the next layout. ### Fix - While the thread follows its tail, a `ScrollMetricsNotification` listener keeps a position that was at its previous end on the new end whenever the extent grows. It only acts when the last reply is laid out, the reader isn't dragging or opted out, and no navigation to the tail is in progress, so programmatic jumps away from the tail are left alone. This also covers other growth at the tail, such as media or typing rows. - A targeted entry now absorbs the replies it hydrates with until its target jump has revealed the list. Hydration can come in two steps (the route's snapshot of the target, then the relay's full thread). Absorbing only the first let the second, when it held the reader's own earlier replies, schedule a tail correction that cancelled the one-shot target jump and left the list hidden behind its loading placeholder. Later arrivals use the ordinary rules: the reader's own reply comes into view, and other people's replies still don't move a reader who is away from the tail. ### Before and after Same flow on an iPhone 17 Pro simulator, in a synthetic QA channel thread: scroll the list away from the bottom, focus the composer, send a long reply that wraps onto many lines. Before (main) the sent reply ends with its bottom edge under the composer; after (this branch) it ends clear above it. Before (main): https://github.com/user-attachments/assets/5101b63d-e2df-46d1-bfd1-49038330ddf5 After (this branch): https://github.com/user-attachments/assets/1df81ad5-bb6b-4df6-b4e3-5c37fd8aab67 ### Validation - Test `a sent reply stays above the composer as it grows in` now runs for a plain entry, a targeted entry (`initialMessageId`), and a targeted entry whose relay hydration is delayed and includes the reader's own replies. The targeted row opens at an older reply, checks it is visible, drags a little, focuses the composer with the keyboard up, sends a multi-line reply, and checks that the reply ends above the composer and **Latest** is hidden. Each row fails without its fix (growth: reply bottom at 504, composer top at 402; targeted: the reply is never followed; delayed hydration: the target never becomes hit-testable because the list stays hidden). - Full `flutter analyze` and mobile test suite pass. - Before/after capture on an iPhone 17 Pro simulator (iOS 27, debug builds of this branch head and of current main), in a synthetic QA channel thread with enough replies to scroll. Flow: scroll away from the bottom, focus the composer, send a long wrapping reply. Over 7 runs on main, the reply's bottom ended 3 to 25 pt under the composer in 5; over 6 runs on this branch, it ended 17 pt above the composer every time. Limitation: debug simulator builds only, and the shortfall on main varies run to run. - Automated full-app run on an iPhone 17 Pro simulator (iOS 27) against the Remote Agents Pilot community. In a test DM thread, I sent short and wrapping replies with no prior scroll, after dragging up and returning, and after the follow-up narrowing. Each reply ended fully above the composer, and **Latest** stayed hidden at the bottom. I also dragged up with the keyboard dismissed and confirmed the list stays at the tail with no stray **Latest**. For the targeted entry, I opened a thread from a message link, scrolled, and sent replies: without the targeted-entry fix they stayed out of view under the keyboard; with it hot-reloaded they were followed (the combined build is covered by the widget test rows above). I then reopened that link right after an app restart, which hydrates in two steps: before the hydration fix the thread showed only its root with the replies hidden, and with it the list revealed at the target and a sent reply came into view. Human testing is waived per the owner's standing exception. Limitation: this run did not cover physical devices or media replies. The simulator recording was taken in an internal community, so it is shared internally rather than embedded here. 🤖 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@localhost> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
22d96d94ef |
[buzz] Recover database pool capacity after stalled connections (#9267)
> **The pin is a temporary workaround for this week's public release.
The SQLx fix is still unmerged.**
>
> 1. We considered a maintained fork, but repository permissions blocked
it.
> 2. We will move to an official SQLx release once it includes the
required fixes and is verified with Buzz. Revisit the fork if needed.
## Bug
https://linear.app/squareup/issue/BUZZ-656/relay-writer-pool-connections-stranded-after-a-burst-bb-public-2026-11
A short database interruption can leave the relay unable to handle
database requests after traffic returns to normal. All connection slots
can remain occupied even though no request is using them and PostgreSQL
is idle. New requests then time out waiting for a slot.
## Cause
SQLx, our database library, returns connections to the pool in a
background task. If a socket stays open but stops answering, the final
health check has no timeout. That task keeps its slot, and
idle-connection cleanup cannot reclaim it
([#4349](https://github.com/transact-rs/sqlx/issues/4349)).
## Fix
Pin SQLx to
[`c3735abf`](
|
||
|
|
65e864301d |
feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
31fa53174d |
Record why relay connections close and quiet close warnings (#9245)
Since bb-public prod moved to `RUST_LOG=error,buzz_relay=info`, two connection-close warnings send about 1.9M lines a day to Datadog. This moves that information to the access log and a metric. - **`close_reason` on the access `close` line.** The first reason recorded wins. When the server has already decided to close (shutdown, a policy disconnect, or a cancellation), the reader defers to that decision, so a peer's reply to the server's Close is never logged as the cause. - Values: `auth_timeout`, `auth_failed` (an AUTH rejected and closed immediately, or a timeout after a rejected AUTH), `missed_pongs`, `client_closed`, `socket_error`, `frame_too_large`, `control_channel_full`, `shutdown`, `server_closed`, `refused` for connections refused before they were served, and `handler_dropped` for a handler dropped before normal cleanup. - A community disconnect logs its own value: `community_archived`, `community_deleted`, `auth_policy_upgraded`, `authorization_denied`, `access_revoked` or `removed`. - **The "NIP-42 auth timeout" and "3 missed pongs" warnings are now `debug!`**, with the same text and fields. - **New counter `buzz_ws_missed_pong_disconnects_total`**, next to `buzz_ws_auth_timeouts_total`. A heartbeat tick that races a close already decided is not counted. Timeouts, ping cadence, the frames sent and when connections close are unchanged. The new infra-free tests are added to the `test-unit` selection and `scripts/run-tests.sh`. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
4bfe60197e |
fix(relay): say "channel owner or admin" in the call_webhook save refusal (#9169)
## Summary
The SEC-006 save gate in `handle_workflow_def` refuses a `call_webhook`
workflow unless the saver is an owner or admin. It looks up the role
with `get_member_role(community, channel_id, …)`, so this is the saver's
**channel** role. The refusal text said only "require the owner or admin
role", which reads as the community owner or admin.
Clients now show the relay's refusal text when a save is refused
(block/buzz-app is about to pass it through as written), so a member
would be sent after the wrong role. The refusal now says:
> forbidden: workflows with call_webhook actions require the **channel**
owner or admin role
- The role check is unchanged.
- The `forbidden:` prefix is unchanged, so clients still treat it as a
permission refusal.
- This now matches the desktop helper copy under the Webhook URL field
("Webhook steps require a channel owner or admin").
No test, fixture, doc or client copy in this repo quoted the old text,
so this is a one-line change.
### Related issue
None found for the save-path wording. #5135 (open) rewrites the SEC-006
refusals on the **trigger** path (`handle_workflow_trigger`) but leaves
this save-path string alone, so the two don't overlap.
### Testing
Not verified locally: no tests, builds, clippy or rustfmt were run, and
the commit was pushed with `--no-verify`. CI is the first check. The
change only edits a string literal.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
8cce12f591 |
feat(mobile): consolidate Solo chat settings in one sheet (#9255)
## Summary Replace the Solo chat header controls with an ellipsis that opens Chat settings, showing the selected agent’s avatar and workspace. Agent and grouped Workspace pickers share one backdrop; closing either returns to settings without flickering. The sheet smoothly resizes to fit each page and respects Reduce Motion. ### Related issue No duplicate found. ### Testing Full pre-push checks pass without exclusions: analysis, formatting, file-size checks, all 4547 mobile tests (67 skipped), push-configuration tests, and secp256k1 tests. Navigation, backdrop continuity, smooth resizing, and Reduce Motion are covered. Updated four stale link-swipe tests to use the leftward reply gesture introduced in #9127. Installed on iPhone Air with saved state preserved. Flutter snapshots with sample data (attached only, not committed): | Chat settings | Workspace | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
8130cd9fc7 |
fix(cli): preserve attachments across successive edits (#9226)
## Summary Editing attachment messages through `buzz messages edit` can remove their files from the effective message. Preserve the latest live imeta set across successive edits and add `--clear-attachments` for explicit removal. Add an SDK builder for complete replacement attachment sets while preserving the existing API. The relay provides a versioned effective-edit lookup on `/query` for kinds 9/40002/40008/45001/45003; media reads fold the same set. It uses the existing normalized-target index, fresh channel authorization, and a primary snapshot. Generic `#e` matching is unchanged. Mixed-case historical edits cannot restore removed files, including when another reference would allow republication. ### Related issue Fixes #3022. Follows #9077. Related open attempts: #3062 and #3065. ### Testing Published head: `c630c4ebf0b20a9618f4844629eec1e666699cbe`, through all mandatory push hooks. The governed-five snapshot passed all four PostgreSQL/HTTP acceptance tests with `RUST_MIN_STACK` unset and 3,333 affected-package tests. The committed test-only array correction passed clippy and the preflight rerun. Independent mutations detected dropped read/lookup kinds, the CLI two-kind split, and preflight ordering, channel and live-edit errors. The published follow-up adds forum preview retention/suppression and a deleted-original preflight fixture; both surviving mutations now fail as intended. Independent review verified the fixes. All four acceptance tests pass; the full DB/relay run had two NIP-FI failures that passed in isolation. The actual standalone MCP workflow passed against this relay and clean local app `5b583b8c69596353bc37e9f3938eca1aabcd081e`: 15 MCP edits, 10 direct publications and 60 member GET/HEAD checks across five isolated blob pairs. Both tested heads are now published. Relay hosted checks report 80 successful and 31 skipped, with none pending or failed; app hosted checks and human upload → edit → clear → edit → GET/HEAD confirmation remain pending. No human acceptance is claimed. ### Rollout The lookup, CLI preservation/clear and media reads use kinds 9/40002/40008/45001/45003. Deploy the relay before releasing the CLI and paired [MCP producer](https://github.com/block/buzz-app/pull/2937). New clients abort without publishing against an unsupported relay. Old CLI edits emit empty imeta and now revoke diff/forum attachments that previously remained readable. Other channel-tagged kinds retain the historical plain-edit path. No additional schema change is needed beyond migration 0086. Readiness remains blocked by mobile edits that omit imeta, the app UI fold that can restore older files, unaudited SDK/direct callers and unmeasured historical exposure. Run `docs/sql/private-media-edit-preflight.sql` for each community; producer updates do not repair signed historical edits. No enforce activation is authorized. `off` skips policy checks, `shadow` changes policy metrics while allowing policy failures, and `enforce` denies inaccessible references. Observe `buzz_media_publication_access_total{mode="shadow",reason="denied"}`. Prefer rolling back the client release while retaining the additive lookup. A relay rollback makes new CLI/MCP edits fail closed; already-published imeta remains valid under the older reference rules. Concurrent edits still use `(created_at,id)` without compare-and-swap. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
d839d3731a |
fix(mobile): swipe left to reply so swipe-back keeps rightward drags (#9127)
Swipe-to-reply used a rightward drag, the same direction as iOS swipe-back. A rightward drag that started on a message armed a reply instead of going back, and the reply recognizer claimed horizontal drags in either direction once they started on a bubble. Now a message slides **left** to reveal Reply on its trailing side, and the reply recognizer only wins the gesture arena for leftward drags. Rightward drags that start on a message are left to back navigation. The threshold, haptic, rubber-band resistance and thread destination are unchanged. On bubbles that reach the row's trailing edge (your own messages), the Reply feedback is kept on screen beneath the bubble, so the slide uncovers it. ### Demo Before (main): a rightward drag that starts on a message arms Reply and slides the bubble, instead of being left to back navigation. https://github.com/user-attachments/assets/f149cd11-c326-466c-a17b-3e4698708af8 After (this PR): a rightward drag on a bubble does nothing, a leftward swipe reveals Reply and opens the thread, and edge swipe-back returns to the channel. https://github.com/user-attachments/assets/539a5682-1032-452e-a57b-6f27abfe2d64 ### Validation - Focused existing validation: the swipe-reply widget tests were mirrored to the new direction for channel and thread rows, own and incoming messages. They now also check that a rightward drag starting on a bubble reaches an outer horizontal drag handler (a stand-in for swipe-back) without moving the row or opening a reply. The Bestie DM test's reply drag was flipped too. - The full mobile suite (`just mobile-check` and `just mobile-test`) passes. - Automated simulator run (iPhone 17 Pro, debug build): swiped left on outgoing and incoming bubbles (feedback shows on the trailing side, thread opens), edge swipe-back from a thread and channel, and a rightward drag starting on a bubble (no reply). The demo above was recorded on a synthetic private channel (own messages only; incoming-message rows were covered in the earlier run and by the widget tests). Human testing is waived under the owner's standing exception. 🤖 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> |
||
|
|
a7a534d476 |
mobile: long-press a message link for open, copy and share (#9135)
Holding a link in a message used to open the whole-message actions sheet, so there was no way to copy or share just the link. Now a hold on a link opens a small **Link** sheet showing the URL with **Open link**, **Copy link** and **Share link**. Web links share as URLs so the share sheet can preview them; other links (such as `buzz://`) share as text. Open link appears only for destinations the app can open (web and `buzz://` links), so a `mailto:` link offers Copy and Share. Repo, PR and issue chips, which have no mobile destination, also offer Copy and Share on hold. While a finger is down, the link gets a light tint behind it, the same kind of press highlight the message row paints. A hold anywhere else in the message still opens the message actions, and a tap on a link still opens it directly. The link gets its own long-press recognizer with the same hold deadline as the message row. Pointer events reach the deeper recognizer first, so at equal deadlines the link wins the arena before the message row, the same mechanism reaction pills already use to claim their own hold. Rendering is otherwise unchanged: the link widget tree only gains a `RawGestureDetector`, a `Semantics` wrapper and a `DecoratedBox` for the tint, press state lives in the link widget so only the link rebuilds, and the sheet is built only when opened. Open reuses the existing tap path (external browser, authenticated relay-media download, or Buzz deep link). ### Validation Added tests: a journey in `message_content_test.dart`, run with both iOS (200 ms) and Android (default) hold timing, that holds surrounding text (message menu opens, link sheet doesn't), holds the link (link sheet, not the message menu), copies it (clipboard gets the URL), opens it from the sheet and by a plain tap, and checks the press tint appears on touch and clears when the finger moves away. Removing the link recognizer or the tint makes it fail. Another test holds a `mailto:` link and checks the sheet offers Copy and Share but not Open. Swipe-to-reply tests in `swipe_reply_tests.dart` drag a message starting on its link and beside it, with both hold timings, and both open the reply. Full `just mobile-check mobile-test` passes (after rebasing onto main, two `hosted_backup_service_test.dart` tests failed once under load and pass on rerun; this PR doesn't touch that code). Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to a Builderlab community, in a private QA channel: held message text (message actions sheet, dismissed and composer focus restored as before), held a link (Link sheet), Copy link (toast, simulator pasteboard holds the URL), held again and Share link (system share sheet with the page preview). The simulator's share sheet then stopped responding to dismissal, a known simulator share-extension quirk unrelated to this change. After adding the tint, a second simulator run held the link and captured the pressed state (below). Not covered: Android device run, VoiceOver. https://github.com/user-attachments/assets/6f5bda14-14af-450e-b021-f98ebc072f9b | Held | Link sheet | Copied | | --- | --- | --- | | <img src="https://github.com/user-attachments/assets/86262d85-b745-41b4-8c40-96372d0cec61" width="300" alt="Link held, tinted"> | <img src="https://github.com/user-attachments/assets/7c9fe05a-17a0-47c4-851e-e89456bc4520" width="300" alt="Link sheet"> | <img src="https://github.com/user-attachments/assets/d97c417a-a79b-4dd2-a43e-35ace7ea5552" width="300" alt="Link copied"> | 🤖 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>mobile-v0.28.0-rc.9 |
||
|
|
e86d0e23a3 |
feat(mobile): long-press a Solo chat to open its details sheet (#9163)
Long-pressing a team channel in the Messages list opens a bottom sheet of channel actions. Long-pressing a Solo chat did nothing, so its settings (star, mute, members, move, copy, leave/archive/delete) were only reachable from inside the chat. Now a long-press on a Solo chat row opens that chat's details screen as a sheet, with the same presentation as the team channel sheet: drag handle, close button, 640 pt max width, 70% max height. The content is the existing channel details page rendered without its app bar (`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full page. Tapping a chat still opens it as before. Solo chats are placed in Solo workspaces, not channel sections, so their details (sheet and full page) no longer show "Move to section…". That row wrote channel sections, which left the chat where it was. Moving a Solo chat between workspaces stays on the chat header's workspace control. ### Demo Long-pressing a Solo chat in the Messages list (Solo filter) opens its details sheet; Star toggles, the sheet scrolls to the lifecycle actions, and the close button dismisses it. Synthetic chat named "QA demo". https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920 ### Validation - Added test: long-pressing a Solo chat on the Me page opens the details sheet with the chat's name, the sheet scrolls to its lifecycle actions, and the channel-section move is absent. - The full mobile test suite and `flutter analyze` pass. - Full automated simulator run on the final code (iPhone 17 Pro, debug build, signed-in Builderlab account, light and dark mode). In the Solo long-press sheet: Star and Mute toggle and revert; Edit, a member's profile and Add members open and close back to the sheet; Copy channel name and Copy channel ID copy the right values and show a toast; the sheet scrolls; the Leave, Archive and Delete confirmations appear and were cancelled; the close button, scrim tap and drag down all dismiss it; "Move to section…" is absent. Tapping the chat still opens it, and its in-chat details page also omits the section move. Regression check: the team channel long-press sheet and team details page are unchanged, including a working "Move to section…". The recording is in Buzz rather than here because it shows internal channel names. Limitation: destructive actions were cancelled, not confirmed. 🤖 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> |
||
|
|
1a0dae77bf |
fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit carousel. While a thumbnail downloads, each cell showed the words "Loading image". Single images and the Flutter carousel already show a shimmering skeleton, so the native carousel stood out. Native carousel cells now show the same shimmer as Flutter's `SkeletonShimmer`: a fill in the theme's primary color at 10% opacity, with a lighter band sweeping across every two seconds. Flutter passes the primary color to the native view along with the brightness. With Reduce Motion on, the fill stays still. Once the thumbnail arrives the shimmer hides. If the thumbnail fails, the cell shows its existing "Image unavailable" message on the plain surface. ### Demo A synthetic private chat with a four-photo message, reopened on a cold launch. To make the loading state easy to see, both builds were patched locally to delay thumbnail delivery by 4 seconds (the patch is not part of this PR). Before (main): each cell shows the words "Loading image" until the thumbnail arrives. https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac After (this PR): each cell shimmers in the theme's primary color until the thumbnail arrives. https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc ### Validation - `just mobile-check` and the full `just mobile-test` suite pass. - Automated simulator run (iPhone 17 Pro, debug build): posted four photos to a synthetic private chat from the app's photo picker, relaunched, and opened the chat on both main and this branch. Before shows "Loading image"; after shows the shimmer, which gives way to the photo when it arrives. Limitations: light mode only, and the 4 second thumbnail delay was a local patch to make the state observable. Dark mode, Reduce Motion and the failed-thumbnail state were not exercised. 🤖 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> |
||
|
|
f3b127e3af |
fix(mobile): keep Home bell tappable while scrolled (#9241)
## Summary Keep the mobile Home bell tappable while scrolling. Reserve both header buttons’ space as soon as the filters overlap the toolbar, and clip filter overflow. ### Related issue No matching open issue or PR found. ### Testing - `just mobile-check` and `just mobile-test` passed. - Android/iOS regression coverage checks the bell at five scroll positions and Activity navigation. iOS uses platform-view hit testing plus its callback; no physical-device verification. - Pre-push checks passed. The additional `just ci` run passed workspace Rust lint and desktop source checks; stopped during desktop native dependency compilation, so full local CI is incomplete. Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
12f3d51001 |
feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 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> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>mobile-v0.28.0-rc.8 mobile-v0.28.0-rc.7 |
||
|
|
794ee64183 |
fix(ios): mint hosted NIP-FI assertions in the notification extension (#9228)
Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>mobile-v0.28.0-rc.6 |
||
|
|
b58ee08404 |
fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
96af488d96 |
fix(media): revoke private channel attachment reads (#9077)
## Summary Removed private-channel members can currently download attachments if they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD and Range requests check current membership and readable references on the primary database. Removed members receive 404; remaining members retain access, and rejoining restores access. Thumbnail aliases use the parent attachment’s permissions. Publication checks also prevent copied private URLs from granting new access. They cover attachments, link previews, profile pictures, banners, group pictures and emoji. Persona creation and changed-avatar edits, plus team and individual snapshot imports, transfer animated posters and animations under the agent’s signer before profile publication. Failed persona transfers preserve existing local records; import failures appear in the results. Authorization uses bounded queries, a two-second statement deadline and transaction-local JIT suppression. An index on edit targets avoids broad scans. Publications have a 512-reference limit, and capacity waits up to 500 ms before refusal. Protection defaults off. Existing downloaded bytes cannot be recalled. All modes stop creating immutable HTTP cache entries. ### Related issue Related approaches: #7565, #5185, #8451 and #8444. This change evaluates live stored references directly. ### Testing Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were **SKIPPED** with explicit author approval. Current-head HTTP, full package, PostgreSQL migration/media and profile-filter mutation checks are pending; CI is pending. The earlier complete HTTP journey passed at `f20b449262` (identical source tree), with its evidence attributed separately. Earlier failing preview/report-fixture runs are preserved; they do not count as passes. The HTTP journey covers upload/publication, fresh GET/HEAD/Range revocation and rejoin, emoji replacement, edited previews, persona visibility and non-reference decoys. The migration regression forces an index timeout, verifies no 0086 history or media tables, then proves a migrator through the base's 0085 can restart. Human attachment removal/rejoin testing passed on the pinned `1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated persona edits and team/individual import acceptance remain incomplete. That old-snapshot database stays intact and cannot run the reconciled binary; a new trial needs a separate fresh database. ### Rollout prerequisites - Build and verify the reference index before shadow mode. Small databases can auto-migrate through one atomic 0086 migration within the configured timeouts. It builds the index before creating the media tables, so failure records no new migration version. Large deployments must disable auto-migration and prebuild indexes concurrently. - **Enforce remains blocked unless the operator can rule out SDK/CLI attachment edits and the unverified agent-harness edit producer.** SDK/CLI edits still omit attachment metadata. That behavior is deferred, not fixed. Removing this blocker requires latest-effective attachment metadata across successive edits, no restoration of removed files, and an upload → successive edits → GET/HEAD regression. - Validate client signer compatibility and split emoji publications above 512 references before enforce mode. - Complete the human workflows above in an isolated community with enforce enabled. - Follow the [index deployment guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md) and [operator guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md) for rollout and rollback. The [prebuild timeout finding](https://github.com/block/buzz/pull/9077#discussion_r4486221478) and [avatar-transfer finding](https://github.com/block/buzz/pull/9077#discussion_r4486221458) are implemented and independently reviewed. ### Schema change checklist - [x] Additive expand and code deployment; no backfill or contract phase. Prebuild/verify before shadow under either schema deployment path. - [x] No SQL function, trigger, procedure, rule or foreign key added. Signed events are untouched. - [x] No migration mutates existing rows. - [x] Migration and desired-state schema match. Catalog parity, target parsing, future partition inheritance and upgrade refusal are covered. - [x] Old/new binaries coexist for serving. Suspend community deletion during expansion and while rolled back to a binary with the old deletion catalog. - [x] Keep auto-migration disabled on old binaries after schema expansion; sqlx rejects unknown versions. - [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated databases build inline within those budgets; large deployments use concurrent child-index prebuild/attachment with auto-migration off. Missing, invalid or wrong indexes fail verification. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> |
||
|
|
2cd40badb8 |
Manage community NIP-FI policy with enable, disable and status (#9176)
## Summary Operators can enable, disable and inspect a community's stored NIP-FI requirement through signed POST/GET `/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`. This replaces the old enable-only endpoint and drops the `nip-fi-upgrade` command added earlier in this PR, along with obsolete compatibility code. Each saved intent binds its desired state and observed generation/state. Every accepted new intent advances the generation, including same-state changes. Committed retries recover history without reapplying policy; stale requests are refused. Current status exposes one pair of reusable `expected_*` preconditions. Historical receipts remain readable after policy changes or target deletion, including old rows with null preconditions. Their UUIDs cannot be reused for new requests. Migration 0085 stays additive; main’s default-channel recovery migration retains version 0084. It preserves old receipt rows and INSERT compatibility. Operator instructions cover use, recovery and client migration: [usage guide]( |
||
|
|
39d5159caf |
[buzz] Recover interrupted default-channel auto-joins (#9045)
**Bug** Default channels are channels that new community members should join automatically. If the database could not list those channels—for example, because its connection pool was busy—an invite claim or an in-app admin add still admitted the member to the community. That member could then remain outside the default channels permanently. **Cause** Community admission was saved before channel joins started. If listing the channels failed, the relay logged the error and stopped. Retrying the invite or admin add did not help: the member already existed, so the relay skipped the joins. **Fix** Save a marker for unfinished channel joins in the same database transaction as admission. If listing or joining channels fails, keep that marker. Retry when the member next authenticates, claims the invite again, or is added again by an admin. One channel's failure does not stop attempts to join the others. Retries preserve the existing membership rules: - A member who leaves a channel during recovery stays out of it. - Work from an earlier admission cannot add channels or clear pending work after the member is removed and admitted again. Each admission has a unique marker, checked under a database lock. - A reconnect still checks whether the member allows the original admin to add them to channels. - Once recovery finishes, later connections do not enroll the member in newly designated default channels. The operator command `buzz-admin add-member` continues to add community membership only. **Limits and rollout risks** Recovery needs one of those later requests; there is no background retry timer. This change does not repair members missed before it was installed. Channel announcements and notifications keep their existing best-effort behavior. A failure after channel membership is saved can still lose an announcement; this change does not replay it. Migration 0084 adds an optional UUID column to `relay_members`. Apply it before running the new code. Older relays ignore the column. The migration has a five-second lock timeout, so it fails rather than waiting indefinitely for its table lock. It adds no triggers, functions, procedures, rules, foreign keys, or data backfill. --------- Signed-off-by: Helen Tian <htian@squareup.com> Co-authored-by: Flipper <1ef64189596befbc8b93f9021bff35ae5d02b155d9e05fc124bca7349927d597@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> |