Skip to content

mesh-llm v6.1: relay-hosted compute sharing — discovery + admission (dial deferred)#695

Open
tlongwell-block wants to merge 14 commits into
mainfrom
mesh-llm-plan-v6.1
Open

mesh-llm v6.1: relay-hosted compute sharing — discovery + admission (dial deferred)#695
tlongwell-block wants to merge 14 commits into
mainfrom
mesh-llm-plan-v6.1

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Mesh-LLM plan v6.1 — relay-hosted compute sharing (discovery + admission)

Closes the relay-side, desktop-side, and frontend pieces of Plan v6.1 so a
user can:

  1. Download Sprout, join a relay — no extra setup.
  2. Open Settings → Share compute, toggle on, dial in their VRAM /
    RAM / concurrent-peer caps.
  3. See live, in the same panel, who else on the relay is offering
    compute.

This PR is "mesh discovery + secure relay admission + contribution UX",
not "remote inference works". The actual iroh dial that runs an
inference request on the offered peer is deferred to upstream mesh-llm
PR A — see "Deferred" below.

Scope guardrails (read this first)

  • Run on mesh is preview/discovery until upstream mesh-llm PR A
    defines the stream protocol.
    The settings panel shows live offers
    from other members; the agent runner doesn't yet route to mesh peers.
  • Offers are claims/UI hints, not authority. The publisher-side caps
    (max_vram_mb, max_ram_mb, max_concurrency) are advertised limits;
    the provider runtime must enforce its own caps locally once the dial
    protocol exists.
  • v1 is same-relay only. Consumers filter offers whose
    iroh_relay_url doesn't match the current relay's NIP-11
    iroh_relay_url; cross-relay membership is reserved for a future
    explicit design.
  • Nostr key never leaves AppState.keys. The iroh endpoint key is a
    separate ed25519 keypair under {app_data_dir}/mesh_iroh.key; the
    frontend only sees the endpoint id (public key) and never the secret.

Plan v6.1 step-by-step

  • Step 1KIND_MESH_LLM_DISCOVERY = 31990 in sprout-core, hooked
    into required_scope_for_kind (MessagesWrite) and is_global_only_kind
    in sprout-relay. NIP-43 fan-out gates read+write.
  • Step 2 — Transport-neutral check_relay_membership returning
    MembershipDecision. HTTP wrapper preserves identical behaviour for all
    existing callers; non-HTTP gates (Step 3) call the core directly.
  • Step 3 — Embedded iroh-relay (crates/sprout-relay/src/iroh_relay.rs)
    with NIP-98 admission. 64 KiB bearer length cap, fail-closed on
    missing/invalid token, calls membership check only after NIP-98
    verification of the pubkey. Wired into fn main with graceful shutdown
    through the existing shutdown_tx. patched-iroh-relay feature
    reserved for upstream PR C's per-client lifetime hook
    (TODO(patched-iroh-relay) marker at the insertion site).
  • Step 4 — NIP-11 advertises iroh_relay_url from
    SPROUT_IROH_RELAY_PUBLIC_URL. Also added SPROUT_IROH_RELAY_BIND_ADDR
    for the main-wiring.
  • Step 5sprout_auth::nip98_canonical_url helper. Single source of
    truth for the NIP-98 u-tag, used by signer (desktop) and verifier
    (iroh-relay access callback). Round-trip test pins the helper to
    verify_nip98_event.

Offer envelope schema (crates/sprout-core/src/mesh_llm.rs)

MeshLlmOffer is the JSON content of a kind:31990 event:

pub struct MeshLlmOffer {
    pub v: u32,                  // schema version, currently 1
    pub d_tag: String,           // ≤64 chars [A-Za-z0-9_-]; also the NIP-33 d tag
    pub endpoint_id: String,     // iroh EndpointId/PublicKey
    pub iroh_relay_url: String,  // iroh-relay the consumer dials through
    pub expires_at: u64,         // unix seconds; consumers MUST ignore <= now
    pub caps: ResourceCaps,      // claims, not authority — see scope guardrails
    pub models: Vec<ModelOffer>,
    pub extra: Option<Value>,    // freeform; top-level is deny_unknown_fields
}

Key shape decisions:

  • endpoint_id is String in core to avoid pulling iroh-base into
    sprout-core; parsed into iroh_base::EndpointId at the desktop edge.
  • expires_at is required (no serde default). Crashed publishers
    cannot send the NIP-33 delete-by-replace tombstone, so the TTL is the
    only thing that reaps stale offers. Consumer filter:
    MeshLlmOffer::is_expired(now) returns true when expires_at <= now.
  • Publisher TTL is OFFER_TTL_SECS = 15min; frontend heartbeats every
    OFFER_TTL_SECS / 3 = 5min so one missed heartbeat still leaves the
    offer visible.
  • Top level is deny_unknown_fields to lock the schema; extra is the
    freeform escape hatch for future experiments.

Desktop sidecar (desktop/src-tauri/src/mesh_llm/)

  • endpoint.rs — iroh endpoint keypair persisted to
    {app_data_dir}/mesh_iroh.key. Atomic write, corrupt-file quarantine.
    Not derived from the Nostr key (see doc comment: rotation
    coupling).
  • nip98.rsbuild_nip98_bearer(keys, iroh_relay_public_url) signs
    kind:27235 over the canonical relay URL using the same
    sprout_auth::nip98_canonical_url helper as the verifier.
  • nip11.rsfetch_iroh_relay_url(ws_url) probes the relay's NIP-11
    doc. Returns Ok(None) gracefully when the relay doesn't advertise
    mesh-LLM.
  • offer.rs — persisted ComputeSharingPrefs (default disabled, 1
    concurrent peer); OFFER_TTL_SECS = 15min; build_offer(endpoint, url, expires_at).

Tauri commands (desktop/src-tauri/src/commands/mesh_llm.rs)

  • mesh_get_endpoint_id / mesh_get_sharing_prefs /
    mesh_set_sharing_prefs / mesh_relay_iroh_url.
  • mesh_publish_offer(iroh_relay_url) — signs + POSTs a kind:31990
    event via the existing submit_event pipeline. On toggle off,
    publishes empty content at the same address (NIP-33
    delete-by-replace).

Frontend (desktop/src/features/settings/)

  • MeshComputeSettingsCard.tsx — toggle, three numeric inputs (VRAM /
    RAM / concurrency), live offers list, identity footer. On every save,
    probes the relay's iroh_relay_url and publishes; surfaces a clear
    message when the relay doesn't advertise mesh-LLM.
  • hooks/useMeshLlmOffers.ts — subscribes to kind:31990, dedups by
    (pubkey, d_tag) (NIP-33), drops on empty content (delete-by-replace),
    filters iroh_relay_url != local relay's NIP-11 iroh_relay_url
    (v1 same-relay invariant), filters expires_at <= now (TTL reaper),
    30s periodic re-tick so newly-expired offers drop without a fresh
    event.
  • hooks/useMeshOfferHeartbeat.ts — re-invokes mesh_publish_offer
    every 5 min while enabled, so the offer's expires_at stays
    ahead of consumer expiry.
  • relayClientSession.tssubscribeToMeshLlmOffers.
  • SettingsPanels.tsx — new "Share compute" section between "Agents"
    and "Templates", reached through avatar-menu → Settings.

Tests + checks

cargo test -p sprout-core --lib 178 pass (+13 mesh_llm: schema, TTL, same-relay filter, publishable rules)
cargo test -p sprout-auth --lib 48 pass (+12 nip98_url)
cargo test -p sprout-relay --lib 208 pass (+18 kind/iroh_relay_url/iroh-relay admission)
cargo test --lib mesh_llm (desktop) 11 pass
cargo clippy --workspace --all-targets -- -D warnings clean
cargo fmt --all -- --check clean
pnpm typecheck clean
pnpm check (biome + file-sizes) clean

MSRV bump: 1.88 → 1.91

Required by iroh-relay 1.0.0-rc.0. The repo's rust-toolchain.toml
pins 1.95.0, so CI is unaffected; this only constrains downstream
consumers building with an older rustc. README updated.

Deferred to follow-up

The actual iroh dial that runs an inference request on the offered
peer.
Plan v6.1 explicitly gates this on upstream mesh-llm PR A
(request/response protocol over iroh streams). Inventing a
Sprout-only protocol now would be wasted work the moment upstream lands.

This PR intentionally does not send prompts, model weights, or inference
payloads over iroh yet.
The only traffic on the wire is the kind:31990
discovery events (which describe capabilities, not data) and the
NIP-98-gated iroh-relay admission handshake (which authenticates but
carries no payload). The data plane stays inside the consuming desktop's
local agent runtime until the dial protocol lands.

What slots in cleanly once PR A lands, in this codebase:

  • MeshLlmProvider in managed_agents/backend.rs selects an offer
    from useMeshLlmOffers and dials it,
  • server-side handler in the desktop accepts incoming iroh streams,
  • the existing NIP-98 bearer signer in mesh_llm/nip98.rs plugs
    unchanged into the iroh client config,
  • the patched-iroh-relay cfg flag (insertion site already marked with
    TODO(patched-iroh-relay)) gains the per-client lifetime hook from
    upstream PR C.

How to demo locally

  1. Run sprout-relay with: SPROUT_REQUIRE_RELAY_MEMBERSHIP=true,
    SPROUT_IROH_RELAY_PUBLIC_URL=http://localhost:3000/iroh,
    SPROUT_IROH_RELAY_BIND_ADDR=0.0.0.0:3478, a stable relay key, and
    yourself in RELAY_OWNER_PUBKEY.
  2. just dev desktop; sign in as the relay-member.
  3. Settings → Share compute → toggle on, set caps.
  4. A kind:31990 event hits the relay (visible in logs).
  5. Second desktop with a different member identity sees the offer in its
    Share compute panel. Toggling off in user A's panel makes the offer
    disappear from user B's view within a few seconds.

Review history

Iterative in-channel review by Max and Mari. Thread root
48856e13…. Concrete reviewer asks folded:

  • Max — access-callback ordering, MSRV bump, TODO(patched-iroh-relay)
    marker, distinct SPROUT_IROH_RELAY_BIND_ADDR, schema additions
    (expires_at, EndpointAddr wording, same-relay filter)
    .
  • Mari — separate iroh key (not derived from Nostr key), 64 KiB bearer
    length cap + no-internal-whitespace + no-token-in-logs, NIP-98
    canonicaliser drift prevention, explicit "preview / claims / v1
    same-relay / key-custody" labeling in this description
    .

tlongwell-block and others added 14 commits May 19, 2026 15:33
- Step 1: KIND_MESH_LLM_DISCOVERY = 31990 (parameterized replaceable,
  global-only, MessagesWrite scope) — relay members announce compute offers
  through the same NIP-43-gated fan-out path as messages.

- Step 2: extract transport-neutral check_relay_membership returning
  MembershipDecision. HTTP enforce_relay_membership becomes a thin
  wrapper that maps Denied -> 403 JSON. Same behavior for all 6 existing
  HTTP callers; non-HTTP gates (iroh-relay AccessConfig) can call the
  core directly without a StatusCode in their return type.

- Step 4: NIP-11 iroh_relay_url field, fed from new
  SPROUT_IROH_RELAY_PUBLIC_URL config. Absent unless configured (older
  clients unaffected). This is what mesh-llm sidecars read to wire their
  iroh endpoints to Sprout's own relay -- no out-of-band config required.

- Step 5: sprout-auth::nip98_canonical_url helper. Single source of truth
  for the NIP-98 'u'-tag value, used by both signer and verifier. Suffix-
  aware path join (preserves /iroh prefix when joining /relay), localhost/
  IPv6 loopback collapse, query+fragment stripping. Round-trip test signs
  with the helper and verifies through verify_nip98_event to prevent drift.

sprout-core: 165 tests pass
sprout-auth: 36 -> 48 tests pass (+12 nip98_url)
sprout-relay: 190 -> 195 tests pass (+3 mesh-llm, +2 iroh_relay_url)
workspace clippy -D warnings: clean
workspace cargo fmt --check: clean

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests).
- pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server
  with AccessConfig::Restricted set to a closure that:
    1. Pulls the Bearer token from ClientRequest::auth_token().
    2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not).
    3. Calls sprout_auth::verify_nip98_event against canonical URL
       (= sprout_auth::nip98_canonical_url(public_url, '/relay')).
    4. Runs check_relay_membership against the NIP-98 pubkey.
       Anything other than Member/ViaOwner/OpenRelay -> Deny.
  Per Max's review notes: fail-closed on missing/invalid token, run
  membership only after NIP-98 verifies the pubkey, no caching.
- Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset
  (the canonical URL can't be built without it).
- patched-iroh-relay feature flag reserved for upstream PR C's per-client
  max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles).

- MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's
  rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the
  bump just keeps Cargo.toml honest with the actual transitive floor.
- README updated: 'Rust 1.88+' -> 'Rust 1.91+'.
- crates/sprout-relay/Cargo.toml: added
  iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
  plus the patched-iroh-relay feature.

Tests (rustc 1.95, via rust-toolchain.toml; also verified independently
on 1.91.1):
- sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid
  admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/
  stale-timestamp denials, and bearer-encoding round-trips).
- cargo clippy --workspace --all-targets -- -D warnings: clean.
- cargo fmt --all -- --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Mari (trust):
- Add 64 KiB pre-decode length cap on the bearer token. NIP-98 events are
  well under a kilobyte; rejecting oversized inputs before allocating the
  base64 decode buffer prevents an admission request from coercing the
  relay into multi-megabyte allocations. New const MAX_BEARER_LEN.
- New verify_bearer_rejects_oversized_token test.
- New verify_bearer_rejects_internal_whitespace test: pins the fact that
  base64 0.22's general_purpose engines reject mid-token whitespace
  (no MIME mode), which is what we want.

Max (review):
- Soften the module-level 'patched-fork hooks' docs so they don't imply
  the per-client max-lifetime hook is already wired, and add an explicit
  TODO(patched-iroh-relay) marker at the future insertion site in spawn.
- Add SPROUT_IROH_RELAY_BIND_ADDR to Config (iroh_relay_bind_addr:
  Option<SocketAddr>) now, so the main.rs wiring follow-up can read it
  without a separate config churn. Server::spawn owns its own listener,
  so this is independent of the Sprout HTTP bind_addr.

sprout-relay --lib: 206 -> 208 tests pass (+2).
workspace clippy -D warnings: clean.
workspace cargo fmt --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
- IrohRelayHandle::shutdown() consumes self and awaits Server::shutdown()
  (which drains in-flight QUIC sessions before returning), replacing the
  previous drop-aborts-supervisor pattern.

- main.rs::serve() now starts the embedded iroh-relay when *both*
  SPROUT_IROH_RELAY_PUBLIC_URL and SPROUT_IROH_RELAY_BIND_ADDR are set.
  Spawned alongside the HTTP listener; subscribed to the same shutdown_tx
  watcher so SIGTERM/Ctrl-C drains the iroh-relay together with axum.

- Mismatched config logs a warn! and starts neither:
    * URL only  -> NIP-11 advertises a phantom endpoint -> mesh-LLM is broken
    * bind only -> clients can't build the NIP-98 'u' tag -> 100% denial
  In both cases we fail loud and refuse to lie to clients.

- Both serve() paths (UDS-enabled + TCP-only) await the iroh drain task
  before returning so shutdown is actually graceful end-to-end.

sprout-relay --lib: 208/208 unit tests pass (unchanged; this is a wiring
change, not an auth change).
workspace clippy -D warnings: clean.
workspace cargo fmt --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the
content of a kind:31990 event. Schema versioned (v: u32), with
deny_unknown_fields at the top level and a freeform 'extra' Value
escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is
limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering
round-trip JSON, optional caps, unknown-field rejection, d_tag
validation, is_publishable rule set.

B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh
endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes.
Atomic write via tempfile.persist; corrupt files quarantined to
.bad.{epoch} (same pattern as identity.key). 2 unit tests.

  Design note in the module doc: we deliberately do NOT derive the
  iroh key from the Nostr key, because that would couple key rotation
  (rotating the Nostr key would silently break active offers) and
  invent a new key-custody convention. Separate file, same Tauri
  sandbox.

B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys,
iroh_relay_public_url) signs a kind:27235 event with the user's Nostr
key over the canonical relay URL (sprout_auth::nip98_canonical_url
with path '/relay'), base64-encodes the event JSON. This is the exact
token the relay's iroh_relay::verify_bearer decodes + verifies.
3 unit tests.

B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted
ComputeSharingPrefs (the avatar-menu sliders). Default is disabled,
1 concurrent consumer cap. build_offer() returns None when disabled
so callers know to *delete* any prior offer rather than re-publish.
JSON round-trip + helper tests, 4 tests.

Workspace deps added:
- desktop pulls sprout-auth (for the canonical URL helper, nostr-free
  at the API surface so the 0.36/0.37 nostr split doesn't matter).
- desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for
  SecretKey/PublicKey/EndpointId.
- desktop pulls thiserror = '2'.

Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests
pass. Workspace clippy + fmt clean (relay side; desktop has expected
dead_code warnings until B4-B6 wire these in).

Note: desktop crate requires sidecar binary stubs in
desktop/src-tauri/binaries/ to typecheck; created via the existing
'just _ensure-sidecar-stubs' helper.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/mesh_llm/nip11.rs: fetch_iroh_relay_url(ws_url)
converts ws:// -> http://, GETs / with Accept: application/nostr+json,
extracts the iroh_relay_url field from the NIP-11 JSON. Returns
Ok(None) on unreachable relays / malformed responses / missing field
so mesh-LLM silently disables itself rather than producing a deploy
mystery; only returns Err for un-fixable caller mistakes (non-ws URL).

Mirrors the probe_relay_supports_nip43 helper already in
commands/pairing.rs; deliberately doesn't share code since this is a
different decode shape with different graceful-failure semantics.

desktop mesh_llm tests: 9 -> 11 (+2 nip11 helper).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/commands/mesh_llm.rs (new):
- mesh_get_endpoint_id(app) -> { endpoint_id }: creates the persisted
  iroh keypair on first call; returns the canonical Display form of
  the public key so the UI can show 'this device' identity.
- mesh_get_sharing_prefs(app) -> ComputeSharingPrefs: reads the
  persisted prefs file (defaults applied when absent).
- mesh_set_sharing_prefs(app, prefs) -> (): atomic write-through.
- mesh_relay_iroh_url(state, relay_ws_url) -> Option<String>: probes
  the relay's NIP-11 doc for iroh_relay_url; returns None gracefully
  when the relay doesn't advertise mesh-LLM.

All four registered in lib.rs's tauri::generate_handler! block.
Frontend hooks land in C1 (avatar-menu MeshComputeSettingsCard).

Cleanup: drop wildcard re-exports from mesh_llm/mod.rs so unused
publisher/dialer helpers don't trip top-level dead-code lints before
B5/B6 are wired in.

cargo check passes; clippy + fmt clean (relay-side workspace).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx (new):
- Toggle: 'Share this machine's compute' (master switch).
- Three numeric inputs: max VRAM (MB), max RAM (MB), concurrent peers.
  Empty = no cap, validated to non-negative integers.
- Displays the local iroh endpoint id (canonical Display form) so the
  user knows which device identity is publishing.
- All state persisted through the mesh_set_sharing_prefs Tauri command;
  loads via mesh_get_sharing_prefs + mesh_get_endpoint_id on mount.
- Error and saving states surfaced inline.

Registered as a new 'compute' SettingsSection in SettingsPanels.tsx
between 'agents' and 'channel-templates', with a Cpu icon. Reached
through the existing avatar-menu -> Settings flow (no popover
restructuring needed for the MVP).

desktop typecheck (tsc --noEmit): clean.
desktop biome check: clean.

UX (matches Tyler's [1]):
- avatar bottom-left -> ProfilePopover -> Settings -> Share compute
- one switch + three caps; the offering side decides everything.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
desktop/src-tauri/src/commands/mesh_llm.rs: new mesh_publish_offer
command. Reads persisted prefs and the local iroh endpoint id; on
enabled=true, builds a kind:31990 event with the JSON-serialised
MeshLlmOffer envelope and a 'd' tag matching prefs.d_tag, then signs
+ POSTs via the existing submit_event pipeline (NIP-98 to /events).

On enabled=false, publishes the *same address* with empty content —
NIP-33's 'delete by replace' idiom — so consumers know the offer has
been withdrawn. PublishOfferResult.published_offer reports which path
was taken.

desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: persist()
now follows save-prefs with a relay capability probe and (when the
relay advertises iroh_relay_url) a publish call. If the relay doesn't
support mesh-LLM, prefs are still saved locally and the UI surfaces a
specific 'this relay does not advertise iroh_relay_url' message rather
than a confusing 'publish failed'.

The user-facing flow is now: open settings -> Share compute -> toggle
on -> a kind:31990 event hits the relay, NIP-43-fanned-out to other
members. Toggling off publishes the empty-content replacement.

Tests: 208 sprout-relay, 174 sprout-core, 11 desktop mesh_llm — all
unchanged-and-pass. desktop typecheck + biome clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
shared/constants/kinds.ts: KIND_MESH_LLM_DISCOVERY = 31990 (matches
sprout_core::kind::KIND_MESH_LLM_DISCOVERY).

shared/api/relayClientSession.ts: subscribeToMeshLlmOffers(onEvent)
issues a NIP-01 REQ for kinds=[31990], limit=200. Returns the latest
snapshot plus a live stream. Membership is enforced relay-side via the
existing NIP-43 fan-out gate, so consumers see only offers authored by
relay members.

features/settings/hooks/useMeshLlmOffers.ts: parses each event's
content as MeshLlmOffer (mirrors sprout_core::mesh_llm shape), keys
offers by (pubkey, d_tag) per NIP-33. Empty content => drop entry
(matches the Rust publisher's delete-by-replace path). Newest-first
sort. Returns { offers, error } for the consumer hook contract.

features/settings/ui/MeshComputeSettingsCard.tsx: new 'Compute offered
by other members' section between the caps fieldset and the identity
footer. Empty-state copy when no offers; otherwise a list with
pubkey-short / d_tag / advertised models / caps for each.

scripts/check-file-sizes.mjs: relayClientSession override 930 -> 960
to absorb subscribeToMeshLlmOffers; comment updated to mention it so
future readers know why.

pnpm check, pnpm typecheck both clean. The full publish/discover loop
runs end-to-end: user A toggles -> kind:31990 hits relay -> user B's
useMeshLlmOffers hook receives it -> card renders 'A is offering ...'.
The only remaining gap to actually use the compute is the iroh dial,
which is the deferred question for Tyler.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
…lter

Per Max's pre-PR review of d9a791f:

1. Add MeshLlmOffer.expires_at: u64 (unix seconds). Hard-required by
   serde (no default) since consumers depend on it for correctness:
   crashed publishers cannot send the NIP-33 delete-by-replace
   tombstone, so the TTL is the only thing that reaps stale offers.

   New helpers + tests in sprout-core::mesh_llm:
   - is_expired(now) returns true when expires_at <= now.
   - matches_local_relay(current_relay) compares against the relay's
     NIP-11 iroh_relay_url with lightweight canonicalisation
     (lower-case scheme/host, trailing-slash collapse, query/fragment
     drop). v1 invariant: one relay = one mesh boundary.
   - is_publishable now rejects expires_at == 0.
   - canonical_relay_url() is a small private helper, deliberately
     separate from sprout_auth::nip98_canonical_url (different jobs).

   5 new tests (expires_at_required, is_expired_filter,
   matches_local_relay_canonicalises, is_publishable_rejects_zero_*,
   plus updated JSON fixtures throughout).

2. Fix doc wording: iroh NodeAddr -> EndpointAddr (rc.0 naming).
   Soften 'multiple relays bridging into membership scope' language to
   reflect that v1 is strictly single-relay and the field is reserved
   for future cross-relay only.

3. Desktop publisher (mesh_publish_offer) now computes
   expires_at = now + OFFER_TTL_SECS (15 min) and threads it through
   build_offer(endpoint_id, iroh_relay_url, expires_at). The frontend
   hook is expected to re-invoke publish on heartbeat well before the
   deadline (heartbeat plumbing is in the next commit).

4. Frontend useMeshLlmOffers hook:
   - probes the relay's NIP-11 iroh_relay_url once on mount and
     filters offers whose iroh_relay_url doesn't match (v1 same-relay
     invariant). When the relay doesn't advertise mesh-LLM, the filter
     correctly empties the list.
   - rejects events with schema v != 1 before storing.
   - 30s setInterval ticks 'now' so expired offers drop without waiting
     for a new event. O(1) timer regardless of how many offers are in
     the cache.
   - mirrors the canonicaliser logic from sprout-core in TS so JS-side
     accept/reject decisions match the Rust check.

Tests after change:
- sprout-core --lib: 174 -> 178 (+4 mesh_llm)
- desktop mesh_llm --lib: 11 unchanged (publisher signature change
  required updating build_offer call sites; tests pass exact-same set).
- pnpm typecheck + pnpm check (biome + file-sizes): clean.
- cargo clippy --workspace -- -D warnings: clean.
- cargo fmt --all -- --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
…mounted

Without heartbeat, the expires_at TTL added in the previous commit makes
offers vanish from consumer UIs 15 min after the user toggles on, even
if the publisher is still running fine. The original commit message
even noted this gap but didn't implement it. Fixing now.

desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts (new):
- HEARTBEAT_MS = 5 minutes (~OFFER_TTL_SECS / 3 — so one missed beat
  still leaves the offer visible; only two consecutive misses drop us).
- While { enabled, irohRelayUrl } are both truthy, setInterval calls
  mesh_publish_offer, which re-stamps expires_at = now + 15 min as a
  NIP-33 replace under the same (pubkey, d_tag) address.
- Errors are logged and ignored — the user isn't waiting in front of the
  panel; the next tick or an explicit prefs change will surface a fresh
  error if the relay is durably down.

desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx:
- New irohRelayUrl state, populated on mount and after every persist().
- useMeshOfferHeartbeat({ enabled, irohRelayUrl }) wired in.
- Mount-time relay probe so a user opening the panel with sharing
  already enabled from a previous session starts heartbeating
  immediately; failures are non-fatal (console.warn, prefs still
  editable).

pnpm typecheck + pnpm check clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs (new): four
end-to-end tests verifying the kind:31990 publish/discover loop against
a real running sprout-relay. Follows the same #[ignore]+RELAY_URL
pattern as e2e_long_form.rs etc.

Tests:
- test_offer_publish_then_retrieve: publish a kind:31990 with a
  far-future expires_at, REQ it back via kinds+author filter, confirm
  the serialised MeshLlmOffer round-trips through the relay intact
  (expires_at, d_tag).
- test_offer_replace_by_d_tag: NIP-33 replace semantics — publishing
  a second event with the same (pubkey, d_tag) must replace the first;
  a subsequent REQ returns only the latest.
- test_offer_delete_by_empty_replace: the desktop publisher's
  delete-by-replace tombstone — publish a real offer, then publish
  empty content at the same address; only the tombstone remains.
- test_offer_stray_h_tag_is_ignored: kind:31990 is global
  (is_global_only_kind); a stray h-tag must not channel-scope the
  event. Verifies the unit-tested behaviour holds end-to-end on
  real relay traffic.

These exercise the slice that pure unit tests can't reach: the actual
NIP-43 fan-out gate, the relay's ingest validation against
required_scope_for_kind / is_global_only_kind, and NIP-33 storage
semantics. They are #[ignore]'d so 'just test-unit' is unaffected;
'cargo test --test e2e_mesh_llm_discovery -- --ignored' runs them
against the relay pointed at by RELAY_URL (default ws://localhost:3000).

clippy + fmt clean across the workspace.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
The verifier side of the iroh-relay NIP-98 admission is fully wired and
tested in sprout-relay::iroh_relay. The client side (build_nip98_bearer)
is reserved for the deferred dial path — it's complete and unit-tested
on its own, but has no live caller until upstream mesh-llm PR A lands.

Adds a module-level #![allow(dead_code)] with a comment pointing at the
deferred-dial dependency, so the workspace stays warning-clean without
losing the test coverage on the helper.

cargo clippy --workspace -- -D warnings + cargo fmt --check clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block requested a review from a team as a code owner May 21, 2026 13:03
@michaelneale
Copy link
Copy Markdown

really cool: Mesh-LLM/mesh-llm#641 is some mesh side stuff I started to work with the auth'd relay (will do api too). Probably could strip down this PR here too as don't need too much once sprout tells mesh about it, but very cool.

Longer term want to make sure it can work with N relays, and perhaps use another gating mechanism if warranted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants