mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
307 lines
16 KiB
Markdown
307 lines
16 KiB
Markdown
# Serverless Mode — Design Report
|
||
|
||
**Branch:** `micn/serverless-mode`
|
||
**Status:** feature-complete, verified end-to-end against live public relays.
|
||
|
||
This report explains what serverless mode is, the design decisions behind it,
|
||
how each piece works, and the evidence that it works end-to-end without
|
||
affecting the existing server-bound ("sprout relay") behaviour.
|
||
|
||
---
|
||
|
||
## 1. What it is
|
||
|
||
Serverless mode (a.k.a. "sprout-lite") lets the Sprout desktop app and the agent
|
||
harness talk **directly to generic public Nostr relays** with **no Sprout
|
||
infrastructure** — no `sprout-relay`, no Postgres, no Redis, no Typesense, no
|
||
auth/membership server. It is a **degraded subset** of Sprout: channels, DMs,
|
||
private channels, messages, threading, and agents — all expressed as plain Nostr
|
||
events over standard relays.
|
||
|
||
A workspace is either:
|
||
|
||
- **`sprout` mode** — talks to a Sprout relay via the HTTP bridge (`/query`,
|
||
`/events`) + NIP-98 auth + NIP-42 AUTH WebSocket. (Unchanged, the default.)
|
||
- **`serverless` mode** — talks to a comma-separated **list** of public relays
|
||
over plain WebSocket (NIP-01 `REQ`/`EVENT`/`EOSE`/`OK`). No AUTH required.
|
||
|
||
Default relays when you enable serverless:
|
||
`wss://relay.damus.io, wss://nos.lol, wss://relay.nostr.band`.
|
||
|
||
---
|
||
|
||
## 2. Guiding design decisions
|
||
|
||
### 2.1 "It's still relays at the end of the day"
|
||
|
||
The core insight that shaped everything: **serverless is the same product with a
|
||
different transport.** The agent's brain (respond gate, threading, prompt
|
||
rendering, reply mechanism) and the desktop's feature surface are shared code.
|
||
Only the bottom transport layer swaps:
|
||
|
||
| Concern | `sprout` mode | `serverless` mode |
|
||
|---|---|---|
|
||
| Respond gate (owner-only default, allowlist, siblings) | shared | **shared** |
|
||
| Threading decision (NIP-10) | shared | **shared** |
|
||
| Prompt rendering / reply via `sprout` CLI | shared | **shared** |
|
||
| **Transport** | HTTP bridge + NIP-98 + NIP-42 | plain WS, multi-relay |
|
||
| Channel discovery | one relay (HTTP) | multi-relay union |
|
||
| Reply / message publish | one relay | multi-relay, first-accepts |
|
||
| Membership push (kind 44100) | relay side-effect | periodic re-discovery |
|
||
|
||
Everything new is **gated** behind `is_serverless()` / `if serverless`. Server
|
||
mode runs the exact same code paths it always has.
|
||
|
||
### 2.2 The standard Nostr client model (no exotic tricks)
|
||
|
||
Relays **do not gossip** — an event stored on relay B is invisible to a
|
||
subscriber on relay A. Every production Nostr client therefore:
|
||
|
||
1. **publishes to many** relays (succeed if any accepts), and
|
||
2. **subscribes to many** relays and **dedups by event id**.
|
||
|
||
This is exactly what damus (`RelayPool.swift`), nostr-tools (`SimplePool`), and
|
||
0xchat do. Serverless mode follows the same model. There is nothing bespoke.
|
||
|
||
### 2.3 Model the operations as Nostr events, not new endpoints
|
||
|
||
Per the project's "prefer Nostr events over new REST endpoints" rule, serverless
|
||
membership/channel operations are done by **writing the same addressable events**
|
||
the relay would have produced (kinds 39000 / 39002), rather than inventing new
|
||
flows. The server's command kinds (9007 create-channel, 9021 join, 44100
|
||
member-added, …) are **server side-effects** that a dumb relay never executes, so
|
||
serverless writes the resulting state events directly.
|
||
|
||
### 2.4 `nostr-relay-pool` for the agent, hand-rolled pool for the desktop
|
||
|
||
The **agent** (`sprout-acp`, a standalone binary) uses the official
|
||
`nostr-relay-pool` crate (rust-nostr SDK) for its relay layer — free
|
||
auto-reconnect, auto-resubscribe, and dedup. The **desktop** deliberately keeps
|
||
its own hand-rolled connection pool (`ws_pool.rs`) to avoid pulling a *second*
|
||
TLS/crypto stack into the Tauri process (which already runs aws-lc-rs rustls for
|
||
reqwest/media). Two rustls providers in one process risk a `CryptoProvider`
|
||
install panic. The desktop pool is serverless-only and already self-heals, so the
|
||
agent's old missed-message bug does not apply there. This asymmetry is documented
|
||
in `ws_pool.rs`.
|
||
|
||
---
|
||
|
||
## 3. How it works, piece by piece
|
||
|
||
### 3.1 Event kinds used
|
||
|
||
All standard Sprout kinds (defined in `sprout-core/src/kind.rs`):
|
||
|
||
- **39000** — channel metadata (addressable; `d` = channel id, `name`, `t`,
|
||
`public`/`private`).
|
||
- **39002** — channel members (addressable; `p` tags, role as the 4th tag
|
||
element: `["p", <pubkey>, "", "owner"|"member"]`).
|
||
- **9** (`KIND_STREAM_MESSAGE`) — a channel message, scoped by the `h` tag
|
||
(NIP-29 group tag).
|
||
- **1059** — NIP-59 gift wrap (encrypted DMs / private channels).
|
||
- **20002** — typing indicator (ephemeral).
|
||
- **5** — NIP-09 deletion (used for channel delete, since the dumb relay won't
|
||
process a delete command).
|
||
|
||
### 3.2 Channels & membership (desktop)
|
||
|
||
- **Create** (`commands/channels.rs`): publish 39000 (metadata) + 39002 (members,
|
||
creator recorded as `owner`) directly to all relays. The `ChannelInfo` is built
|
||
**locally** and returned immediately — no re-query race (this fixed the early
|
||
"nothing happened" bug).
|
||
- **Join / leave / add / remove**: read the current 39002, modify the `p` list
|
||
(preserving roles), and re-publish (read-modify-write). NIP-29 command kinds are
|
||
no-ops on a dumb relay, so direct state writes are the only thing that works.
|
||
- **`get_channels`**: in serverless, skips the server's `limit:5000` network
|
||
discovery (which pulled hundreds of unrelated public channels) and scopes to the
|
||
user's own membership.
|
||
- **Critical fix:** nostr 0.44 strips self-`p` tags unless
|
||
`.allow_self_tagging()` is set. Without it, the creator's own membership tag was
|
||
dropped, so `get_channels` (`#p:[me]`) found nothing. All serverless membership
|
||
builders set `.allow_self_tagging()`.
|
||
|
||
### 3.3 DMs & private channels (encryption)
|
||
|
||
- **DMs**: `open_dm` derives a deterministic UUIDv5 channel id from the two
|
||
pubkeys (so both sides converge on the same id), then publishes 39000 + 39002.
|
||
- **Encryption (NIP-17 / NIP-59)**: a DM or private channel message is built as a
|
||
normal kind-9 **rumor** (unsigned, carries the `h` tag), then **gift-wrapped
|
||
(kind 1059) once per recipient** — every channel member plus self. Only the
|
||
gift wraps hit the relay; the plaintext kind-9 never does. The `h` tag lives
|
||
*inside* the encrypted rumor, so a relay query by `#h` returns nothing — the
|
||
channel itself is hidden.
|
||
- **Threaded encrypted replies** (the privacy fix in this branch): replies in a
|
||
private channel are **also** gift-wrapped, with the NIP-10 thread `e` tags
|
||
placed *inside* the rumor. Previously replies fell through to the plaintext
|
||
path and leaked the reply content to public relays. Now threading is preserved
|
||
*and* encrypted. The thread root is resolved locally from already-decrypted
|
||
messages (the parent rumor isn't queryable in plaintext on the relay).
|
||
|
||
### 3.4 Multi-relay transport
|
||
|
||
- **Desktop** (`ws_pool.rs` + `ws_relay.rs`): one long-lived WebSocket per relay,
|
||
multiplexing all queries/publishes. Reads fan out and **merge + dedup by event
|
||
id**; writes fan out and **succeed if any relay accepts** (with a retry if all
|
||
rate-limit). Dropped connections are detected and reconnected on next use. This
|
||
fixed the rate-limit storm (the original code opened a fresh socket per op) and
|
||
the "message shows then disappears" split-brain (publish landed on relay B,
|
||
live-read was on relay A).
|
||
- **Agent** (`serverless_relay.rs` wrapping `nostr-relay-pool`): a persistent
|
||
multi-relay subscription with built-in auto-reconnect + auto-resubscribe. On
|
||
reconnect the SDK re-sends the `REQ`, and the relay replays its stored matching
|
||
events — so a message that arrived during a blip is recovered. Events are merged
|
||
and deduped across relays automatically.
|
||
|
||
### 3.5 Agents (the headline capability)
|
||
|
||
Agents work the **same** as in server mode — the permission model is identical;
|
||
only the transport differs.
|
||
|
||
- **Launch**: managed agents launch with the **current workspace** relay list and
|
||
`SPROUT_SERVERLESS=true`. A startup gate (`workspace_applied`) makes agent
|
||
restore wait (up to 15s) for the frontend to apply the workspace, so agents
|
||
never launch with stale relays or in the wrong mode.
|
||
- **Serverless detection**: the agent infers serverless from a comma-list relay
|
||
URL (`SPROUT_RELAY_URL=wss://a,wss://b`) in both `sprout-acp` and the `sprout`
|
||
CLI, so even the agent's reply (`sprout messages send`) takes the serverless
|
||
path.
|
||
- **Discovery**: the agent finds channels it belongs to by querying 39002
|
||
(`#p` = agent) across all relays. Because a dumb relay never emits the
|
||
membership-added push (kind 44100), the agent **re-runs discovery every 20s** and
|
||
subscribes to any newly-joined channels.
|
||
- **Receiving messages**:
|
||
- *Public channels*: live `REQ` for kind 9 scoped by `#h` = channel.
|
||
- *Private channels / DMs*: a **gift-wrap inbox** subscription (kind 1059,
|
||
`#p` = agent). On each wrap the agent decrypts to the inner rumor, reads its
|
||
`h` tag, and routes it to the right channel.
|
||
- **The respond gate** (unchanged from server mode): default **`owner-only`** —
|
||
the agent only acts on messages from its **owner** (the npub that added it,
|
||
resolved from the `SPROUT_AUTH_TAG` NIP-OA attestation), plus "siblings" (other
|
||
agents launched by the same owner). Other modes: `allowlist`, `anyone`,
|
||
`nobody`. The gate runs in the harness **before** the LLM sees the event.
|
||
- **Replying**: the agent posts its reply via the `sprout messages send` CLI
|
||
(multi-relay, first-accepts). It addresses the asker by `@name` in the channel —
|
||
it does **not** DM the owner. Threading mirrors the trigger: a reply to a
|
||
top-level message is top-level; a reply inside a thread uses `--reply-to` and
|
||
stays threaded.
|
||
- **Working cues**: while a turn is in flight the agent publishes a typing
|
||
indicator (kind 20002, every 3s) and a `💬` reaction, so the UI shows "working…"
|
||
during the (30–90s) LLM latency.
|
||
|
||
---
|
||
|
||
## 4. Bugs found and fixed on this branch
|
||
|
||
In rough order, each reproduced and then fixed (and most now covered by a test):
|
||
|
||
1. **"Join did nothing"** — membership via server command kinds is a no-op on a
|
||
dumb relay → switched to direct 39002 read-modify-write; then `.allow_self_tagging()`.
|
||
2. **Rate-limit storm** — fresh socket per op → persistent connection pool.
|
||
3. **"Message shows then disappears"** — split-brain reads → multi-relay
|
||
read/write/subscribe.
|
||
4. **Query flood** — `limit:5000` discovery pulled the whole public network → skip
|
||
in serverless.
|
||
5. **Channel delete no-op** → NIP-09 kind-5 deletion.
|
||
6. **Agent DNS crash** — comma-list fed to a single connect → multi-relay connect.
|
||
7. **Agent didn't see new channels** (no 44100 push) → 20s re-discovery.
|
||
8. **Agent reconnect storm** — a paid relay's `auth-required` → drop that sub,
|
||
don't reconnect-loop; removed paid relays from defaults.
|
||
9. **Agent launched in server mode / stale relays** → `workspace_applied` gate +
|
||
serverless inference from comma-list.
|
||
10. **Agent reply lost to rate-limit** → multi-relay fan-out + backoff retry.
|
||
11. **Agent relay layer fragile** (missed messages on relay drop) → replaced with
|
||
`nostr-relay-pool` (auto-reconnect/resubscribe/dedup).
|
||
12. **`💬` working-cue never landed** (500ms timeout too tight for a real WS
|
||
round-trip) → 4s.
|
||
13. **Encrypted replies leaked plaintext** → gift-wrap replies too, NIP-10 tags
|
||
inside the rumor.
|
||
|
||
---
|
||
|
||
## 5. Verification — evidence it works end-to-end
|
||
|
||
All run on `micn/serverless-mode` at the time of this report.
|
||
|
||
### Automated
|
||
|
||
- **Unit tests:** `sprout-acp` 272 ✅, `sprout-cli` 114 ✅, `sprout-sdk` 138 ✅,
|
||
desktop backend 427 ✅ (all pass).
|
||
- **Clippy:** clean on every touched crate (acp, cli, sdk, desktop backend).
|
||
- **End-to-end against live public relays** (`crates/sprout-acp/tests/e2e_agent_responds.rs`,
|
||
runs the **real** `sprout-acp` + **real** `sprout` CLI, no LLM stub):
|
||
- `agent_responds_in_channel_e2e` — proves: receive @mention → reply #1 →
|
||
**typing indicator (kind 20002)** during the turn → second message → reply #2
|
||
(**cancel/redispatch** path). ✅
|
||
- `agent_responds_in_private_channel_e2e` — proves: a **gift-wrapped (kind
|
||
1059)** message is received, **decrypted**, routed by the inner `h` tag,
|
||
gated, and replied to. ✅
|
||
- Both pass reliably (run repeatedly) **even while damus returns 503** — the
|
||
multi-relay fan-out fails over to another relay.
|
||
- **Encryption unit test** (`desktop/src-tauri/src/encrypted.rs`): a threaded
|
||
reply in a private channel is gift-wrapped (kind 1059), the plaintext never
|
||
appears on the wire, and the NIP-10 root/reply tags survive decryption. ✅
|
||
|
||
### Manual (live)
|
||
|
||
- Confirmed on the relays directly that a user's **private** channels show **zero
|
||
plaintext kind-9** and only un-`h`-tagged gift wraps.
|
||
- Confirmed goose replies in **public** channels live (addresses the user by
|
||
display name).
|
||
|
||
---
|
||
|
||
## 6. Will it break `main` (server mode)?
|
||
|
||
**No.** Risk is contained:
|
||
|
||
- **Every serverless behaviour is gated** behind `is_serverless()` (desktop) or
|
||
`if serverless` / `Option<ServerlessRelay>` being `None` (agent). The
|
||
`ServerlessRelay`/`nostr-relay-pool` path is only constructed inside
|
||
`if serverless`. Server mode runs the original code paths unchanged.
|
||
- **New modules** (`ws_pool.rs`, `ws_relay.rs`, `encrypted.rs`,
|
||
`serverless_relay.rs`, `ServerlessContext.tsx`, `defaultRelays.ts`) are reachable
|
||
only from serverless branches.
|
||
- **The single shared, non-gated change** is `REACTION_TIMEOUT` (500ms → 4s). It
|
||
only makes a best-effort reaction *more* tolerant; server mode's HTTP bridge
|
||
responds well under 4s, so it cannot regress server behaviour.
|
||
- **Dependency additions** (`nostr-relay-pool`, rustls feature on the CLI) resolve
|
||
to the same `nostr 0.44.3` already in the tree — no version conflict.
|
||
- The one failing workspace unit test (`git-sign-nostr::test_parse_envelope_rejects_invalid_oa_pubkey`)
|
||
**also fails on `main`** — it is pre-existing and unrelated to this branch.
|
||
|
||
---
|
||
|
||
## 7. Known limitations / follow-ups
|
||
|
||
- **Public relays are unreliable stores.** They rate-limit, 503, and may not
|
||
retain low-traffic events indefinitely. The multi-relay fan-out + auto-reconnect
|
||
mitigates this, but serverless is inherently best-effort — appropriate for a
|
||
"lite" mode, not a system of record.
|
||
- **Profile/display names** depend on the relays carrying kind-0 metadata; absent
|
||
that, the agent may address users by raw npub.
|
||
- **Desktop `ws_pool.rs` is hand-rolled** (by deliberate choice, §2.4). If the
|
||
desktop ever shows a concrete live-subscription recovery bug, that targeted path
|
||
can be hardened without adopting a second TLS stack.
|
||
- **Server-mode integration suite** (`just test`, needs Postgres + Redis) was not
|
||
run for this report; the serverless changes are gated so server paths are
|
||
unaffected, but running it is the recommended final gate before merge.
|
||
|
||
---
|
||
|
||
## 8. Key files
|
||
|
||
| Area | File |
|
||
|---|---|
|
||
| Serverless detection / mode | `desktop/src-tauri/src/app_state.rs`, `commands/workspace.rs` |
|
||
| Desktop relay transport | `desktop/src-tauri/src/relay.rs`, `ws_relay.rs`, `ws_pool.rs` |
|
||
| Channels / DMs / messages | `desktop/src-tauri/src/commands/{channels,dms,messages}.rs` |
|
||
| Encryption (gift wrap) | `desktop/src-tauri/src/encrypted.rs`, `commands/encrypted.rs` |
|
||
| Agent relay (SDK pool) | `crates/sprout-acp/src/serverless_relay.rs`, `relay.rs` |
|
||
| Agent main loop / re-discovery / gate | `crates/sprout-acp/src/lib.rs` |
|
||
| Agent + CLI serverless detection | `crates/sprout-acp/src/config.rs`, `crates/sprout-cli/src/lib.rs` |
|
||
| CLI multi-relay client | `crates/sprout-cli/src/client.rs` |
|
||
| Shared event builders | `crates/sprout-sdk/src/builders.rs`, `desktop/src-tauri/src/events.rs` |
|
||
| Frontend serverless UI | `desktop/src/features/workspaces/*` |
|
||
| E2E proof | `crates/sprout-acp/tests/e2e_agent_responds.rs` |
|
||
| Deeper implementation map | `docs/SPROUT_LITE_MODE.md` |
|