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>
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>
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>
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>
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>
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_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-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>
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>