Files
buzz/AGENTS.md
Ian Oberst 347ce88d5d fix(db): keep pgschema re-apply from dropping runtime partitions (#8487)
## Summary

pgschema doesn't compare partition bounds. Re-applying
`schema/schema.sql` to a database a relay has used therefore plans `DROP
TABLE IF EXISTS ... CASCADE` for every `events` / `delivery_log` monthly
the partition manager created at runtime, and those tables' rows are
deleted with them. Since #8464 the manager advances the catch-all by
default, so any local dev or test database a relay has started against
now hits this. One example is the persistent `buzz` database that
`scripts/start-relay-for-tests.sh` re-applies.

This adds a root `.pgschemaignore` that hides dated monthlies
(`events_p2*`, `delivery_log_p2*`) from pgschema. The monthlies declared
in `schema/partitions/` are exempted with `!` patterns, so pgschema
still manages them. pgschema reads the file from its working directory,
and every caller already runs it from the repo root: the test-relay
launchers, `postgres-test-setup.sh`, the desktop release smoke and
`_ci-relay.yml`. Both partition files now carry a note pointing at it,
and AGENTS.md gotcha #7 adds "run pgschema from the repo root" next to
the reconcile rule.

New-install layout is unchanged.

**Why the drops haven't deleted data yet:** on a reconciled database a
re-apply also plans to drop and re-add the `events` primary key on the
parent and on each declared child. The apply then fails with `cannot
drop inherited constraint "events_p2026_01_pkey"` (42P16). That happens
with or without this PR. The planned drops run in the same single
transaction, so they roll back, and the runtime monthlies have survived
so far. Once that primary-key drift is fixed, re-applies will succeed
and this ignore file is what keeps them from dropping the runtime
monthlies. The primary-key fix is a separate follow-up.

### Related issue

Follow-up to #8464. Salvaged from an earlier branch; no existing issue
found.

### Testing

- `pgschema_reapply_keeps_runtime_partitions` (Postgres) follows the
real bootstrap path in a dedicated database:
- `bin/pgschema apply` from the repo root, then
`scripts/reconcile-schema-after-pgschema.sql`;
  - the relay's default maintenance pass (create plus advance);
- asserts that both catch-alls were attached before the pass and that
their bounds moved after it;
- asserts that each parent's `p2026_07` monthly exists. The catch-all
advanced past the declared bounds but kept its name.
- asserts that `pgschema plan` contains no `DROP TABLE` and no line
mentioning any runtime monthly.
- Falsified both ways:
- With `.pgschemaignore` removed, the test fails and prints the planned
drops.
- Without the reconcile step, it fails with `reconcile did not attach
both catch-alls`.
- `pgschemaignore_exempts_exactly_the_declared_monthlies` checks that
the `!` exemptions exactly match the dated monthlies in
`schema/partitions/`.
- The admin parity test now shares the `pgschema` helper, so it also
runs from the repo root like the real callers.
- All `runtime::migration` and `store::partition` tests pass against
local Postgres after merging current main, including the ignored ones:
104 passed, run with `--test-threads=1` because the full nextest
Postgres lane needs local `dropdb`. `clippy -D warnings` and the
pre-push lanes pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:14:22 -07:00

833 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md — AI Agent Contributor Guide
This guide is for AI agents contributing to the Buzz codebase. It covers
agent-specific context and conventions. For general contributor info (setup,
code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
---
## Product Contract
Before planning or reviewing a non-trivial change:
1. Read [VISION.md](VISION.md).
2. Read the `VISION_*.md` documents relevant to the affected product surface.
3. Read the applicable guidance in [TESTING.md](TESTING.md) and any
package-local `TESTING.md`.
4. For any change to `migrations/`, `schema/`, or schema-dependent `buzz-db`
queries, read [docs/database.md](docs/database.md): no new triggers,
functions, or foreign keys; no data backfills in migrations; update every
schema location; and keep every migration compatible with the relay that
is currently deployed.
5. Check that the proposed design advances, or at least does not contradict,
that product intent. Call out any intentional tension explicitly.
Implementation describes the product today; the vision documents describe the
product it is becoming. A locally correct change can still be wrong if it works
against that direction. Scale validation to the change's risk and exercise the
real workflow for user-visible or integration behavior when practical; green CI
and runtime evidence answer different questions.
---
## Before opening a PR
Open work in progress as a draft PR. Mark it ready for review only when this
checklist holds for the current change. Scale it to what changed:
documentation-only changes need content, link, and diff checks plus human
confirmation, not app runs.
1. **Agent review ran** under [Reviewing](#reviewing), and its recommended
blockers were fixed or explicitly declined by the human author. Optional
suggestions do not gate readiness.
2. **An agent exercised the changed behavior.** Client changes: the affected flow
in the app, using the native app or a device when the behavior needs it
(browser or headless Playwright counts only for what it can exercise). Relay
changes: a local relay, exercising the changed events or endpoints. CLI or
tooling changes: the affected command or workflow. The human may skip this
step, for example for a small change or while iterating.
3. **A human then tested it themselves**: in the app, against the local relay
(through the app or `curl`), or by running the changed command. Agent testing
does not substitute. Agents give the human exact steps and what working looks
like, then wait for explicit confirmation. Never mark this step done yourself.
4. **Add `buzz-review-completed` to the PR description** once steps 1–3 hold. It
attests the checklist, and automated reviewers may skip review because of it.
If later edits change behavior, remove it and return the PR to draft until the
affected steps are redone.
## Reviewing
- Before reviewing, read [VISION.md](VISION.md), the `VISION_*.md` docs for the affected surface, and the PR's stated goal and linked
issue. Review the change against what it is trying to do.
- Check the change against the [Review-Proven Rules](#review-proven-rules): the defects reviewers here find most often.
- Judge minimalism, elegance, and correctness, aiming for 9/10 on each. A score
below 9 names the concrete defect and the fix.
- Recommend blocking only for concrete correctness, security, or agreed-contract
defects with a realistic failure scenario: state the defect, how it fails, and
the fix. Label everything else (nits, wording, speculative hardening,
out-of-scope improvements) as optional.
- Put all findings in the first review. Later reviews check prior blockers and
defects the fixes introduced; reopen other areas only on new evidence of a
material defect.
- Agents post reviews as comments, never Request Changes. Humans decide which
findings must be fixed.
## Ecosystem
Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
| Repo | Purpose |
|------|---------|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
```
block/buzz (source)
├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► sprout-oss (relay Docker image → ECR)
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
```
See [RELEASING.md](RELEASING.md) for the desktop release flow and
[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor
access information.
---
## Repo Structure
```
crates/
# Relay + core
buzz-relay # WebSocket relay server — main entry point; also hosts git + huddle audio
buzz-core # Core types, event verification, filter matching, kind registry
buzz-db # Postgres event store and data access layer
buzz-auth # Authentication and authorization
buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators
buzz-search # Postgres FTS full-text search
buzz-audit # Hash-chain audit log
buzz-media # Blossom/S3 media storage
# Agent surface
buzz-acp # ACP harness bridging Buzz events to AI agents
buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
buzz-dev-mcp # Developer MCP server — shell + file-edit tools
buzz-persona # Agent persona packs
buzz-workflow # YAML-as-code workflow engine (evalexpr conditions)
# Clients + interop
buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing
buzz-pairing-cli # CLI for NIP-AB device pairing interop testing
git-sign-nostr # Sign git objects with a Nostr key
git-credential-nostr # Git credential helper for Nostr-authed push/fetch
# Tooling + shared
buzz-cli # Agent-first CLI
buzz-sdk # Typed Nostr event builders
buzz-admin # Operator CLI for relay administration
buzz-ws-client # Shared NIP-42 WebSocket client (connect, auth, publish)
buzz-test-client # Integration test client and E2E test suite
sprig # All-in-one harness bundling ACP, agent, and dev MCP
desktop/ # Tauri 2 + React 19 desktop app
web/ # Browser web client (repo browser, served by the relay)
mobile/ # Flutter mobile app
migrations/ # SQL migrations (run at relay startup when BUZZ_AUTO_MIGRATE is on: binary default off, Helm default on; deployments at scale apply them out of band — see docs/database.md)
schema/ # Desired-state schema (schema/tables/public/ is the declarative schema source)
scripts/ # Dev tooling
.env.example # Config template — copy to .env before running
```
---
## Getting Started
```bash
. ./bin/activate-hermit # activate hermit toolchain (Rust, Node, etc.)
cp .env.example .env # configure local environment
just setup # install deps, run migrations
just relay # start relay at ws://localhost:3000
just ci # run before any PR
```
See CONTRIBUTING.md for full setup details and dependency requirements.
---
## Quality Gates
Run `just ci` before every PR — it runs repository-wide formatting, lint,
and static checks; Rust, Tauri, desktop, and mobile tests; and desktop and web
builds. Clippy passing does not mean fmt passes; run both.
For changes limited to Flutter/Dart code in the mobile app, run
`just mobile-install mobile-check mobile-test` instead of `just ci`.
Native code and build-configuration changes also require the corresponding
platform checks.
Run `just test` for integration tests if you touched `buzz-relay`,
`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.
**Pre-commit hooks** are installed automatically by `just setup` and auto-fix
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
commit. **Pre-push hooks** run the repository-wide differential file-size gate,
clippy (workspace + Tauri), desktop TypeScript typechecking (`tsc --noEmit`),
and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter)
— no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix
all formatting in one shot. Run `just ci` for the full local gate. Run `just
hooks` to re-install hooks after env changes. Each globbed pre-push lane is
scoped to the branch's merge-base diff against `origin/main` (`git diff
origin/main...HEAD`), matching CI's paths-filter — so a lane only fires when this
branch actually changed a file it covers, never because `origin/main` moved.
These lanes validate the checked-out HEAD; pushing a non-HEAD ref (explicit
refspec, `--all`) gets a non-fatal `push-head-scope` warning and relies on CI for
its path-scoped checks.
Before agents run Git or hooks, activate the repo's Hermit environment
(`. ./bin/activate-hermit`) so `./bin` leads `PATH` and the pinned toolchain
(flutter, dart, lefthook) wins over any Homebrew version; do not
rewrite hook commands to compensate for an unconfigured shell `PATH`. The
pre-push hook self-pins regardless: `bin/.lefthookrc` (sourced by the generated
`.git/hooks/*`) prepends the Hermit `bin/` to `PATH` and pins `LEFTHOOK_BIN`, so
lane subprocesses resolve the pinned flutter/dart/lefthook even when an
unactivated shell has Homebrew first. Activating Hermit remains recommended for
non-hook commands.
**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push.
Additional rules:
- No `unsafe` code
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
- New public API must have doc comments
---
## Review-Proven Rules
These rules distill the recurring findings from the last 25 PRs' review
threads — 53% of substantive review findings were repeats of the clusters
below, and reviewed PRs averaged ~5 review rounds. A second, independent
mining pass over 71 agent-review rooms (303 findings, Aug 18–29) confirmed
the same clusters and measured how often authors actually fix each class
once flagged: test-seam binding and unbounded-resource findings were fixed
**100%** of the time, swallowed-error findings **90%**, stale-state races
**70%** — these are not style opinions, they are defects authors agree
with on sight. Authors apply them **before writing code**, and reviewers check
against them; each cites the PRs where reviewers litigated it.
1. **Every caught failure must leave a durable retry record or propagate.**
Never catch-log-and-return-success (opt-out revocation permanently
abandoned, PR #6269), never convert a terminal failure into an
authoritative success/empty result (cold-history `error` → `success`
with `[]`, PR #7013), and never delete the durable journal an operation
depends on before its retry has actually succeeded (PR #6269). If a
partial failure can orphan committed state (installations, endpoints),
schedule its cleanup/renewal durably (PRs #6269, #6996, #7013).
2. **Fence async results by generation; clear derived metadata on every
removal path.** A completing in-flight probe or fetch must verify it is
still the newest before writing its result (stale login-shell probe
recached a false-negative PATH, PR #6904). Provenance/ownership metadata
attached to synthetic state must be updated or cleared on *all* paths
that remove or refresh that state — typed deletion, toolbar removal,
profile/name refresh; enumerate the paths and test each (PR #6956 burned
4 rounds on this one class). Backfill and live subscriptions must
overlap — a gap between a finite history REQ and the live subscription
silently drops events (PR #3995); a retired chunk must not keep a stale
scope fence (PR #6996). (PRs #3995, #6904, #6956, #6996)
3. **Regression tests must bind the production seam and be falsifiable.**
See "Review-Proven Test Standards" in [TESTING.md](TESTING.md) for the
full rule — in short: a guard whose removal doesn't fail any test
protects nothing; bind regression tests to the production code path,
not test-only helpers. (PRs #6807, #6980, #6996, #7013)
4. **Bound every resource, loop, and process tree.** Cap captured
output (unbounded discovery temp files exhausted disk and overran the
deadline, PR #6904). Containment failures are errors, not warnings — a
tolerated Job Object creation failure or a `setsid` escape leaks whole
process trees (PR #6904). Retry/re-subscribe loops need backoff and a
terminal state: a persistent failure must not self-amplify into an
unbounded refresh loop (PR #6996), and check zero-delay edge cases
(`remainingMs()==0` selected the wrong fallback window, PR #6996).
(PRs #6904, #6996)
5. **One user action = one atomic persist.** Implementing a single user
commit as N independent durable writes leaves torn state on partial
failure (theme "Set" as three independent notifier persists, PR #6944;
relay-commit vs. local-save recovery gap, PR #6269). Persist one
snapshot, or order the writes so every prefix is consistent and the
remainder is durably retried per rule 1. (PRs #6269, #6944)
6. **A guard that hides the only recovery affordance is a functional
failure.** Before adding a visibility predicate or state fence, ask:
if the state it assumes goes wrong, does the user still have a way
back? A fence that permanently suppresses "jump to latest" after a
bounded correction fails strands the user silently — two reviewers
flagged this independently (PR #6807).
7. **Audit assistive semantics on every new visual component.** The
agent-review lanes flagged accessibility defects on 44 findings across
the Aug 18–29 window — the second-largest cluster — and authors fixed
the concrete ones (duplicate VoiceOver stops on native controls,
actionable labels owned by two widgets at once, PR #6680; missing or
decorative-leaking semantics on new UI, PRs #6611, #6702, #6885, #6905,
#6908). New UI ships with: one owner per actionable label, no duplicate
screen-reader stops, and explicit semantics for every interactive
element. (PRs #6611, #6680, #6702, #6885, #6905, #6908, #6980)
8. **Every input modality is a first-class seam.** Keyboard, pointer, and
hotkey paths must not silently diverge: `Shift+Space` treated as plain
`Space` because the guard omitted `shiftKey` (PR #6862), keyboard
ownership not released on blur, modifier keys dropped on the non-mouse
path (PRs #5958, #6793, #6860, #6908, #7006). When adding an input
handler, enumerate the modalities that can reach it and test the
non-primary ones — that's where the defects were. (PRs #5958, #5972,
#6793, #6860, #6862, #6908, #7006)
---
## Key Patterns
**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.
**Prefer Nostr events over new HTTP endpoints**: For new feature work, model
the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler
in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is
reserved for things that genuinely need an HTTP-only surface: media upload/download
(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,
and the generic Nostr bridge endpoints:
- `POST /events` — submit any signed event (same path the WebSocket uses).
- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters
are routed to `buzz-search` (Postgres FTS) automatically.
- `POST /count` — Nostr COUNT filters over HTTP.
If you find yourself reaching for a new HTTP endpoint, first check whether
an event kind would do the job — it usually will, and you get realtime
fan-out, NIP-29 scoping, and the existing auth pipeline for free.
Reference https://github.com/nostr-protocol/nips
**Event kinds**: All event kind integers are defined in
`buzz-core/src/kind.rs`. New features get new kind integers — add them here
first, then implement handling in the relay.
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
Filters and queries must scope to `h` tags when operating within a channel.
This applies to events *inside* a channel. Addressable events that describe a
channel carry its id in their `d` tag instead: kind:39000 (metadata),
kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels
from the `d` tag of their kind:39002 events, not from `h`. The relay leaves
system channels out of these reads unless the filter asks for them (see
`docs/nips/NIP-CL.md`).
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
**Workflow conditions**: `buzz-workflow` uses
[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
simple and testable.
**Thread counters**: `reply_count` and `descendant_count` are materialized on
thread root events. Any code that inserts replies must update these counters —
check existing reply handlers for the pattern.
---
## Agent CLI (`buzz-cli`)
`buzz` is the agent-first CLI. Auth env vars
(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected
by the ACP harness into managed agent subprocesses. In development, set
`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.
### Building the CLI
```bash
cargo build --release -p buzz-cli
```
Binary location: `./target/release/buzz`. Add `./target/release` to `PATH`
or invoke with the full path.
### Deep Links
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
thread. Pass the link directly to the CLI:
```bash
buzz --format compact messages thread --link '<buzz://message?...>'
```
The selected message ID is authoritative: `messages thread` verifies its
channel and derives its containing root. An optional `thread` parameter is
accepted only when it matches that derived root. The explicit
`--channel <uuid> --event <hex>` form remains available.
All event reads return normalized JSON arrays. Normal output preserves the seven
canonical signed Nostr event fields (`id`, `pubkey`, `kind`, `content`,
`created_at`, `tags`, `sig`); all writes return
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).
`--format` is a **global** flag — it goes before the subcommand:
`buzz --format compact channels list`, NOT `buzz channels list --format compact`.
Without the flag it falls back to `BUZZ_OUTPUT_FORMAT` (unknown values mean
`json`); buzz-acp sets that to `agent` for agent
processes, so their event reads drop `sig`, trim the NIP-OA `auth` tag to
`["auth", <owner>]`, and fold the NIP-10 `root`/`reply` tags into `reply_to`
(plus `thread_root` on nested replies); other tags remain.
See `crates/buzz-cli/TESTING.md` for the full live-testing runbook.
---
## Testing
```bash
just test-unit # unit tests, no infrastructure needed
just test # full integration suite (requires Postgres + Redis)
```
E2E tests live in `crates/buzz-test-client/tests/`:
- `e2e_relay.rs` — WebSocket relay protocol
- `e2e_media.rs` — media upload/download (Blossom)
- `e2e_media_extended.rs` — extended media scenarios
- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)
Desktop E2E: `cd desktop && pnpm test:e2e:smoke` for mock-bridge smoke
coverage, or `pnpm test:e2e:integration` for relay-backed coverage. These
scripts build the required E2E bridge before running Playwright.
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
### PR Screenshots
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
> from a PR body/comment. If you hand-edit PR markdown, run
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
For mobile simulator screenshots, save the PNGs in a local directory and run
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
with a markdown template containing `{{filename}}` placeholders.
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
starts preview server, runs Playwright automatically):
```bash
just desktop-screenshot --name home
just desktop-screenshot --name channel --route /channels/general
just desktop-screenshot --name search --click open-search
just desktop-screenshot --name settings --click open-settings
```
Options: `--name` (filename), `--route` (client route), `--active-channel`
(channel to view), `--click` (left-click data-testid or CSS selector),
`--right-click` (right-click for context menus), `--hover` (hover before
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
Output is a PNG path on stdout.
Use `--messages` to inject content into a channel before capture. The JSON file
is an array of objects — `channelName` and `content` are required, all other
fields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:
```json
[
{
"channelName": "random",
"content": "Hey @tyler check this out",
"pubkey": "953d...",
"kind": 40002,
"mentionPubkeys": ["deadbeef..."],
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
"parentEventId": "abc123"
}
]
```
Without `--active-channel`, all messages must target the same channel and the
helper navigates to that channel (useful for showing message content). With
`--active-channel`, messages can target multiple channels while the "camera"
stays on the specified channel (useful for unread indicators, badges, etc.).
```bash
# Messages in the channel you're viewing (code blocks, formatting, etc.)
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
# Messages in OTHER channels to trigger unread state
just desktop-screenshot --name unread-dot \
--active-channel general --messages /tmp/badge-msgs.json
# Cropped to sidebar only (256px wide)
just desktop-screenshot --name sidebar-unread \
--active-channel general --messages /tmp/badge-msgs.json \
--clip 0,0,256,720
# Context menu on an unread channel (wider crop to include popup)
just desktop-screenshot --name ctx-mark-read \
--active-channel general --messages /tmp/badge-msgs.json \
--right-click channel-random --clip 0,200,320,300
# Hover state (e.g. copy button reveal)
just desktop-screenshot --name copy-hover \
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
```
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch
(`agent-screenshots/<github-username>`) and posts a PR comment with
commit-SHA-based image URLs (immutable — safe from later overwrites):
```bash
./scripts/post-screenshots.sh 803 test-results/screenshots
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
```
The body file supports `{{filename}}` placeholders (without `.png`) to inline
images at specific positions. Images not referenced by any placeholder are
appended at the end. Without placeholders, all images are appended (backward
compatible).
```markdown
### Unread dot
A message arrives in `#random`.
{{01-unread-dot}}
### Context menu
Right-click shows "Mark as read".
{{02-context-menu}}
```
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
branch, but the script **appends a new PR comment** — it does not edit or
delete the previous one. After reposting, delete the superseded comment so
only the current set remains, otherwise reviewers still see the stale images:
```bash
# List screenshot comments to find the stale one's id
gh pr view <pr> --repo block/buzz --json comments \
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
gh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>
```
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
### Writing E2E Screenshot Specs
When screenshots need seeded state, live messages, or UI interaction before
capture, write a Playwright spec instead of using `just desktop-screenshot`.
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri
bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured`
in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so
`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails
with `Cannot read properties of undefined (reading 'invoke')` — the app renders
"Community connection failed" instead of the UI under test. That looks exactly
like a product bug rather than a build mistake, so it burns real time.
`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for
you; prefer them over a manual build plus `playwright test`.
**Stale server:** `reuseExistingServer: true` means a previous build's server
serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running
tests after code changes.
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
bridge triggers mount.
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
subscription. Navigate to the channel first (triggers subscription), then away
(so unread indicators appear), then inject.
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
resolves mid-animation — wait for completion before screenshotting. Use the
shared helper (mandatory before any `page.screenshot()` or
`locator.screenshot()` in specs):
```ts
import { waitForAnimations } from "../helpers/animations";
// ... after the element is visible but before capturing:
await waitForAnimations(page);
await page.screenshot({ path: "...", clip: { ... } });
```
The `just desktop-screenshot` path (`screenshot.mjs`) calls
`waitForAnimations` automatically — no manual step needed there.
For per-element waits (rare — prefer the page-level helper above):
```ts
await menuItem.evaluate((el) =>
Promise.all(
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
),
);
```
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
for sidebar features. Sidebar = 256px; context menus ~450px.
**Distinct states — verify before posting:** when one view renders many
elements at once (e.g. all team cards in a single grid), an unscoped
full-page `page.screenshot()` captures the *same* pixels for every shot, so
multiple PNGs come out byte-identical. Scope each shot to its subject with
`locator.screenshot()` (full-page `clip` only when an overlay like an open
dropdown must be included). Then gate on hash distinctness before posting:
```bash
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
```
Identical hashes mean two shots captured the same state — fix the spec, do
not post. This catches the most common screenshot regression.
**`general` has pre-seeded messages** making `hasUnread` always true. Use
`engineering` for "muted + no unread" visual states.
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
description. See [PR #803](https://github.com/block/buzz/pull/803).
---
## Common Gotchas
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
3. **`messages search` chooses its own supported kinds** — do not add a `--kinds` option; the current command does not accept one. This differs from raw relay filters, which still need explicit kinds.
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
7. **`pgschema` omits seed DML and some storage parameters** — `schema/schema.sql` is an ordered `\i` manifest: `schema/tables/public/<table>.sql` holds only that table's `CREATE TABLE` and `CREATE INDEX` statements (one table per file, the shape out-of-band schema tools read), and types, functions, partitions, triggers, and seed rows live in the sibling `schema/` directories. Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script, and must run pgschema from the repository root so it reads `.pgschemaignore`; from any other directory a re-apply plans to drop the partitions the relay created at runtime. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state.
---
## Desktop App
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
organized under `desktop/src/features/`. Biome handles linting and formatting.
```bash
just desktop-dev # web-only dev server (faster iteration)
just dev # full Tauri app with native shell
```
### Text sizing & zoom (use rem, never px)
The desktop app implements Cmd +/- zoom by scaling the root `<html>`
font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native
webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes
are frozen.**
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
px:
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general
interface text. All of these derive from the virtual typography rem and
therefore follow the user's font-size preference and Cmd +/- zoom.
- ✅ Conversation text uses the named `text-message` token. Its
**Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard
zoom. Author names use the same conversation-size step; timestamps, system
rows, code, and reactions are deliberate neighboring steps on the shared
virtual-rem ramp. Keep those relationships tokenized rather than restoring a
fixed 16px chat baseline or hardcoding preference-specific values in
components.
- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs`
(0.5rem / 8px at a 16px virtual rem) meta-text
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary
values.
- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom
and caused the message-timeline regression (PR #891).
- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They
zoom fine but re-fragment the scale we consolidated. Use a named token.
Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
needs a size the stock/`2xs`/`3xs` scale can't express should you **add a
rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)
rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in
`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any
new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
script.
### Community Switching
The desktop app supports multiple communities (each backed by a different relay).
Switching communities does **not** reload the page — it uses React key-based
remounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire
community-scoped subtree to unmount and remount with fresh state.
**Module-level singletons must be explicitly reset.** React remounting only
clears React state (useState, useRef, context). Module-level variables (Maps,
class instances, cached promises) survive across remounts. Every community-scoped
singleton needs a reset function wired into `resetCommunityState()` in
`desktop/src/features/communities/useCommunityInit.ts`.
`resetCommunityState()` is the canonical inventory of community-scoped
singletons. **If you add a new module-level cache, Map, or class instance that
holds community-scoped data, add its reset there in the same change.** Failure
to do so causes data from the old community to leak into the new one. Avoid
duplicating its complete reset list here; the implementation is the source of
truth.
Key files:
- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)
---
## Mobile App (Flutter)
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
### Architecture
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
stay in sync with `desktop/src/shared/constants/kinds.ts`
### Rules
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
- Agents may build and run the Flutter app when it materially helps implement,
debug, or validate mobile changes. Prefer the smallest relevant command and
reuse an already-running simulator/emulator and the app's configured staging
or production community when that is sufficient. Do not start or rebuild
local relay services unless the task specifically requires relay-side or
isolated integration behavior.
- For iOS runtime validation, prefer `just mobile-dev`; it applies the
worktree-specific debug identity and runs `flutter run`. Direct `flutter run`
or IDE workflows are also allowed. Use `just mobile-build-android` only when
an APK build is relevant to the task.
- Do not rebuild, reinstall, or relaunch merely for ceremony. Preserve Flutter's
incremental build cache and use hot reload/restart where appropriate. Use
`flutter clean` only when stale build artifacts are a credible cause. Run
`flutter upgrade` only when the task explicitly requires a toolchain change.
- For user-visible or integration changes, exercise the affected workflow in a
real app when practical and report the device/simulator, connected community,
and workflow actually tested.
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
over raw `Theme.of(context)` calls.
- **Keep widgets small and composable.** One public widget per file; push
private sub-widgets (`_Foo`) into sibling `part` files under a
`<page>/` folder rather than growing the page file. Mobile's hard ceiling is
**1200 lines/file**, enforced with the other surface-specific limits by the
repository-level `just file-size-check` gate (`just check`, CI, and every
pre-push). If an individual file trips the guard, **split the file — never
bump a surface limit or add an override merely to admit that file.**
Deliberate repository-wide policy revisions must update the enforced rules,
tests, and guidance together.
- Feature modules must not import from other feature modules — only from
`shared/`.
- Use `Grid` tokens for spacing, `Radii` for border radius.
### Quality Checks
```bash
cd mobile
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
```
Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
To run the app locally with a worktree-specific debug identity and a
started or reused iOS Simulator:
```bash
just mobile-dev
```
This runs `flutter run` against the app's configured community; it does not
start Docker or local relay services.
When run from a git worktree, `just mobile-dev` (and `just
mobile-build-android`) give the debug build a per-worktree app identifier
(keyed to the worktree directory name) and a branch-labelled app name via
`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees
install side by side. Release builds are unaffected. `just mobile-clean`
removes stale worktree-suffixed installs from simulators/emulators. See
[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio
usage.
### Testing Conventions
Favor a few functional tests over many small ones. Every test is code we
maintain, so each one must protect a distinct user outcome or invariant.
- **Start with journeys.** For each user-visible flow, write one widget test
that drives it through real widgets, notifiers and coordinators. Assert what
the user sees and what is durably saved, not internal stages or call counts.
Where Flutter can observe it, include semantics and non-primary input in the
journey: screen-reader labels and actions, keyboard, alternate gestures, text
scale and reduced motion.
- **Put failure paths inside the journey.** Retry, cancel, resume and a second
request arriving midway are steps of a journey, not separate tests.
- **Fake only external boundaries:** network, relay, OS, browser and storage.
Keep the flow's own notifiers and coordinators real. Override boundary
providers with `ProviderScope(overrides: [...])`, using
`WidgetHelpers.testable()` or a custom `ProviderScope` + `MaterialApp`.
- **Use focused tests only for what a journey can't enumerate or observe.**
That means security and data-loss invariants, protocol or crypto vectors,
pure functions with many input combinations, and accessibility or platform
behavior owned by native code that a widget journey can't observe, such as
VoiceOver stops and traits, Dynamic Type layout and reduced motion in native
views. Make them table-driven where cases repeat.
- **Extend before adding.** When a review finds a defect, add a step to an
existing journey or a row to an existing table. Don't add a new test per
finding. When a Review-Proven Rule says to test each path, cover the paths
as journey steps or table rows.
- **Delete what a journey covers.** Remove a lower-level test once a journey
demonstrably catches the same failures; passing through the same path is not
enough. Keep distinct safety, interop, accessibility and native-platform
invariants.
- **Prove it can fail.** Break the production code a test guards once and
confirm the test fails. A test that only exercises a fake protects nothing.
---
## See Also
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
- [README.md](README.md) — project overview and quick start
### Mention editor contract
Autocomplete inserts a literal full label and a separator, including multi-word
names. Only autocomplete settlement may move the caret past that separator;
internal label spaces and deliberate ArrowLeft/click movement must be respected.
See `docs/mention-editor.md` and `desktop/tests/e2e/mention-spacing.spec.ts`.
Selected mention labels bind exact keys, including same-name teammates and
persistent automatic addresses. Use the returned label from registration for
insert/restore/remove. Ambiguous manually typed names must fail visibly without
clearing the draft in chat, edit, and standalone forum consumers; never fan out
silently to all identities sharing a name. See `docs/mention-editor.md`.