mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): keep shared-compute consumers admitted (WIP: multi-workspace publishing) (#2000)
Signed-off-by: Mat Balez <60949391+matbalez@users.noreply.github.com> Co-authored-by: Mat Balez <60949391+matbalez@users.noreply.github.com>
This commit is contained in:
co-authored by
Mat Balez
parent
f054df7366
commit
5d77fa5749
@@ -47,16 +47,52 @@ const RELAY_MESH_RUNTIME_NO_TARGET: &str =
|
||||
|
||||
pub type CmdResult<T> = Result<T, String>;
|
||||
|
||||
fn advance_mesh_status_cursor(
|
||||
filter: &mut serde_json::Value,
|
||||
page: &[nostr::Event],
|
||||
) -> Result<(u64, String), String> {
|
||||
let last = page
|
||||
.last()
|
||||
.ok_or_else(|| "cannot advance an empty mesh status page".to_string())?;
|
||||
let cursor = (last.created_at.as_secs(), last.id.to_hex());
|
||||
filter["until"] = serde_json::json!(cursor.0);
|
||||
filter["before_id"] = serde_json::json!(cursor.1);
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
async fn query_mesh_discovery_events(state: &AppState) -> Result<Vec<nostr::Event>, String> {
|
||||
let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?;
|
||||
let member_pubkeys = mesh_llm::current_member_pubkeys(&events);
|
||||
if member_pubkeys.is_empty() {
|
||||
return Ok(events);
|
||||
}
|
||||
let mut status_filter = mesh_llm::mesh_status_filter();
|
||||
status_filter["authors"] = serde_json::json!(member_pubkeys);
|
||||
let mut previous_cursor: Option<(u64, String)> = None;
|
||||
|
||||
loop {
|
||||
let page = relay::query_relay(state, &[status_filter.clone()]).await?;
|
||||
let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE;
|
||||
if !done {
|
||||
let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?;
|
||||
if previous_cursor.as_ref() == Some(&cursor) {
|
||||
return Err("mesh status pagination did not advance".to_string());
|
||||
}
|
||||
previous_cursor = Some(cursor);
|
||||
}
|
||||
events.extend(page);
|
||||
if done {
|
||||
return Ok(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the admission roster by intersecting member-signed mesh status
|
||||
/// reporters with the current NIP-43 direct-member list. Missing membership or
|
||||
/// a failed query returns an empty roster, which the runtime normalizes to
|
||||
/// self-only admission.
|
||||
pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Vec<String> {
|
||||
let filters = [
|
||||
mesh_llm::mesh_status_filter(),
|
||||
mesh_llm::relay_membership_filter(),
|
||||
];
|
||||
match relay::query_relay(state, &filters).await {
|
||||
match query_mesh_discovery_events(state).await {
|
||||
Ok(events) => mesh_llm::owner_ids_from_events(&events),
|
||||
Err(error) => {
|
||||
eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}");
|
||||
@@ -272,14 +308,7 @@ pub(crate) async fn resolve_mesh_bootstrap_target(
|
||||
if model_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let events = relay::query_relay(
|
||||
state,
|
||||
&[
|
||||
mesh_llm::mesh_status_filter(),
|
||||
mesh_llm::relay_membership_filter(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let events = query_mesh_discovery_events(state).await?;
|
||||
Ok(pick_serve_target_for_model(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
model_id,
|
||||
@@ -426,6 +455,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_status_cursor_uses_relay_composite_tiebreak() {
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status")
|
||||
.custom_created_at(nostr::Timestamp::from(1_234))
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign test status");
|
||||
let mut filter = mesh_llm::mesh_status_filter();
|
||||
|
||||
let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event))
|
||||
.expect("advance status cursor");
|
||||
|
||||
assert_eq!(cursor, (1_234, event.id.to_hex()));
|
||||
assert_eq!(filter["until"], serde_json::json!(1_234));
|
||||
assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
|
||||
assert_eq!(
|
||||
filter["limit"],
|
||||
serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_returns_first_match_for_model() {
|
||||
let targets = vec![
|
||||
|
||||
@@ -184,18 +184,45 @@ pub async fn apply_workspace(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
let state = restore_app.state::<AppState>();
|
||||
if state
|
||||
let restore_pending = state
|
||||
.managed_agent_restore_pending
|
||||
.swap(false, Ordering::AcqRel)
|
||||
.swap(false, Ordering::AcqRel);
|
||||
|
||||
// The coordinator starts before React applies the selected workspace, so
|
||||
// its startup publication may have used the fallback relay and placeholder
|
||||
// identity. Correct it off the command path so an unavailable relay cannot
|
||||
// hold the frontend on its loading gate. On initial launch, restore MeshLLM
|
||||
// first so a slow stopped-status request cannot overwrite a newly restored
|
||||
// serving status, then restore managed agents after the admission identity
|
||||
// has been published (or the bounded publication attempt has timed out).
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
{
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
|
||||
if restore_pending {
|
||||
if let Err(error) =
|
||||
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
|
||||
}
|
||||
}
|
||||
crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
|
||||
if restore_pending {
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
if restore_pending {
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
{
|
||||
|
||||
@@ -8,10 +8,34 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use mesh_llm_client::models::catalog::{parse_size_gb, MODEL_CATALOG};
|
||||
use mesh_llm_client::network::nostr::auto_model_pack;
|
||||
use mesh_llm_node::models::{default_huggingface_cache_dir, scan_installed_models};
|
||||
use mesh_llm_system::hardware;
|
||||
use mesh_llm_system::vram::format_rated_capacity;
|
||||
use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb};
|
||||
|
||||
/// Buzz-curated tier picks. These are the models we know survive the agent
|
||||
/// harness on shared compute — deliberately non-reasoning instruction models,
|
||||
/// so agents stay snappy instead of burning hidden reasoning tokens.
|
||||
///
|
||||
/// The large pick is resolved through mesh-llm's remote catalog
|
||||
/// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in
|
||||
/// the compiled `MODEL_CATALOG`; the entry is synthesized below.
|
||||
const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M";
|
||||
const CURATED_LARGE_SIZE: &str = "17GB";
|
||||
const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf";
|
||||
const CURATED_LARGE_DESCRIPTION: &str =
|
||||
"Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines";
|
||||
const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M";
|
||||
/// Rated-capacity boundary between the two curated tiers, in GB (marketing
|
||||
/// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less).
|
||||
const CURATED_LARGE_MIN_RATED_GB: u64 = 64;
|
||||
|
||||
/// The Buzz-curated recommendation for a machine's rated memory capacity.
|
||||
fn buzz_recommended_model(rated_gb: Option<u64>) -> &'static str {
|
||||
match rated_gb {
|
||||
Some(gb) if gb >= CURATED_LARGE_MIN_RATED_GB => CURATED_LARGE,
|
||||
_ => CURATED_SMALL,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a model sits inside this machine's usable AI memory.
|
||||
/// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds.
|
||||
@@ -57,6 +81,9 @@ pub struct MeshCatalogEntry {
|
||||
pub fit: ModelFit,
|
||||
pub installed: bool,
|
||||
pub recommended: bool,
|
||||
/// Buzz-curated pick — known to survive the agent harness. Curated
|
||||
/// entries render above the fold; everything else is "advanced".
|
||||
pub curated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -123,6 +150,7 @@ fn build_catalog(
|
||||
fit: fit_code(size_gb, vram_gb),
|
||||
installed: is_installed(&m.file, &m.name),
|
||||
recommended: false,
|
||||
curated: false,
|
||||
name: m.name.clone(),
|
||||
size: m.size.clone(),
|
||||
size_gb,
|
||||
@@ -131,14 +159,36 @@ fn build_catalog(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let recommended = auto_model_pack(vram_gb).into_iter().next();
|
||||
// The compiled MODEL_CATALOG does not know the Buzz large pick; it
|
||||
// resolves through mesh-llm's remote catalog at download time. Synthesize
|
||||
// its entry so the picker can offer it.
|
||||
if !entries.iter().any(|e| e.name == CURATED_LARGE) {
|
||||
let size_gb = parse_size_gb(CURATED_LARGE_SIZE);
|
||||
entries.push(MeshCatalogEntry {
|
||||
fit: fit_code(size_gb, vram_gb),
|
||||
installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE),
|
||||
recommended: false,
|
||||
curated: false,
|
||||
name: CURATED_LARGE.to_string(),
|
||||
size: CURATED_LARGE_SIZE.to_string(),
|
||||
size_gb,
|
||||
description: CURATED_LARGE_DESCRIPTION.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let recommended = Some(buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string());
|
||||
for entry in &mut entries {
|
||||
entry.recommended = recommended.as_deref() == Some(entry.name.as_str());
|
||||
// Both curated tiers are always offered: the recommended one for this
|
||||
// machine plus the other pick (e.g. the small one as an explicit
|
||||
// lighter choice on big machines).
|
||||
entry.curated = entry.name == CURATED_LARGE || entry.name == CURATED_SMALL;
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
b.recommended
|
||||
.cmp(&a.recommended)
|
||||
.then(b.curated.cmp(&a.curated))
|
||||
.then(fit_rank(a.fit).cmp(&fit_rank(b.fit)))
|
||||
.then(b.size_gb.total_cmp(&a.size_gb))
|
||||
});
|
||||
@@ -191,11 +241,11 @@ mod tests {
|
||||
assert!(catalog.entries[0].recommended);
|
||||
}
|
||||
}
|
||||
// Fit ranks must be non-decreasing after the recommended head.
|
||||
// Fit ranks must be non-decreasing after the recommended/curated head.
|
||||
let ranks: Vec<u8> = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.skip_while(|e| e.recommended)
|
||||
.skip_while(|e| e.recommended || e.curated)
|
||||
.map(|e| fit_rank(e.fit))
|
||||
.collect();
|
||||
assert!(
|
||||
@@ -205,12 +255,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_uses_mesh_llm_auto_selection() {
|
||||
let catalog = build_catalog(None, 62_000_000_000, 62.0, &[]);
|
||||
assert_eq!(
|
||||
catalog.recommended,
|
||||
auto_model_pack(62.0).into_iter().next()
|
||||
);
|
||||
fn recommendation_follows_buzz_curated_tiers() {
|
||||
// 64GB+ rated machines get the large curated pick.
|
||||
let large = build_catalog(None, 64_000_000_000, 64.0, &[]);
|
||||
assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE));
|
||||
let big = build_catalog(None, 128_000_000_000, 128.0, &[]);
|
||||
assert_eq!(big.recommended.as_deref(), Some(CURATED_LARGE));
|
||||
// Below the boundary: the small curated pick — never a reasoning
|
||||
// model, never sub-4B guesswork.
|
||||
let small = build_catalog(None, 32_000_000_000, 32.0, &[]);
|
||||
assert_eq!(small.recommended.as_deref(), Some(CURATED_SMALL));
|
||||
let tiny = build_catalog(None, 16_000_000_000, 16.0, &[]);
|
||||
assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_picks_lead_the_catalog() {
|
||||
let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]);
|
||||
// Recommended curated entry first, the other curated pick second,
|
||||
// advanced entries after.
|
||||
assert_eq!(catalog.entries[0].name, CURATED_LARGE);
|
||||
assert!(catalog.entries[0].recommended && catalog.entries[0].curated);
|
||||
assert_eq!(catalog.entries[1].name, CURATED_SMALL);
|
||||
assert!(catalog.entries[1].curated && !catalog.entries[1].recommended);
|
||||
assert!(catalog.entries[2..].iter().all(|e| !e.curated));
|
||||
// The synthesized large pick carries a real size for fit ranking.
|
||||
assert!(catalog.entries[0].size_gb > 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@ pub const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = buzz_core_pkg::kind::KIND_BOOKMARK
|
||||
const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status";
|
||||
const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45);
|
||||
const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct MeshCoordinator {
|
||||
_status_publisher: tokio::task::JoinHandle<()>,
|
||||
@@ -38,13 +39,14 @@ pub async fn start_coordinator(app: AppHandle) {
|
||||
|
||||
let publisher_app = app.clone();
|
||||
let status_publisher = tokio::spawn(async move {
|
||||
// Clear a stale serving status promptly after an app restart. Once the
|
||||
// node is stopped, the explicit stopped event is sufficient; only a
|
||||
// running node needs periodic freshness heartbeats.
|
||||
// Clear a stale serving status promptly after an app restart. Keep
|
||||
// publishing while stopped too: serving nodes build their admission
|
||||
// allowlist from fresh member statuses, so consumer-only identities
|
||||
// must remain fresh even though they advertise no serving targets.
|
||||
publish_current_status_once(&publisher_app, "startup").await;
|
||||
loop {
|
||||
tokio::time::sleep(STATUS_PUBLISH_INTERVAL).await;
|
||||
publish_running_status_once(&publisher_app).await;
|
||||
publish_current_status_once(&publisher_app, "heartbeat").await;
|
||||
}
|
||||
});
|
||||
let roster_app = app.clone();
|
||||
@@ -106,22 +108,31 @@ async fn reconcile_roster(state: &AppState) -> Result<(), String> {
|
||||
|
||||
pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) = publish_current_status_for_state(&state).await {
|
||||
eprintln!("buzz-mesh: status report after {reason} failed: {error}");
|
||||
match tokio::time::timeout(
|
||||
STATUS_PUBLISH_TIMEOUT,
|
||||
publish_current_status_for_state(&state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => eprintln!("buzz-mesh: status report after {reason} failed: {error}"),
|
||||
Err(_) => eprintln!("buzz-mesh: status report after {reason} timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) = publish_stopped_status_for_state(&state).await {
|
||||
eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_running_status_once(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) = publish_running_status_for_state(&state).await {
|
||||
eprintln!("buzz-mesh: periodic status report failed: {error}");
|
||||
match tokio::time::timeout(
|
||||
STATUS_PUBLISH_TIMEOUT,
|
||||
publish_stopped_status_for_state(&state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
|
||||
}
|
||||
Err(_) => eprintln!("buzz-mesh: stopped status report after {reason} timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,23 +161,6 @@ async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String
|
||||
publish_status_report(state, payload).await
|
||||
}
|
||||
|
||||
async fn publish_running_status_for_state(state: &AppState) -> Result<(), String> {
|
||||
let identity = super::ensure_owner_identity()
|
||||
.map_err(|error| format!("failed to load mesh owner identity: {error}"))?;
|
||||
let mut payload = {
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
let Some(runtime) = runtime.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
runtime
|
||||
.status_report_payload()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
};
|
||||
bind_payload_to_member(state, &identity, &mut payload)?;
|
||||
publish_status_report(state, payload).await
|
||||
}
|
||||
|
||||
fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"ownerId": identity.owner_id,
|
||||
@@ -234,10 +228,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heartbeat_leaves_room_before_status_expires() {
|
||||
fn member_heartbeat_leaves_room_before_admission_status_expires() {
|
||||
assert!(
|
||||
STATUS_PUBLISH_INTERVAL.as_secs() * 2 < super::super::discovery::STATUS_FRESHNESS_SECS
|
||||
);
|
||||
assert!(STATUS_PUBLISH_TIMEOUT < STATUS_PUBLISH_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M
|
||||
/// than two minutes so crashed/offline devices stop contributing compute or
|
||||
/// admission identities without requiring a relay-side cleanup job.
|
||||
pub(super) const STATUS_FRESHNESS_SECS: u64 = 120;
|
||||
pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100;
|
||||
|
||||
fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
|
||||
event
|
||||
@@ -34,16 +35,22 @@ fn dedupe_targets(targets: Vec<MeshServeTarget>) -> Vec<MeshServeTarget> {
|
||||
/// contribute an owner id. This removes stale notes from former members and
|
||||
/// ignores notes from nonmembers. If the relay has no membership snapshot, the
|
||||
/// roster is empty and MeshLLM admission therefore remains self-only.
|
||||
///
|
||||
/// Admission deliberately ignores status freshness: membership is the trust
|
||||
/// boundary, and a member whose device is offline (stale status) is still a
|
||||
/// member. Gating admission on freshness caused the allowlist — and therefore
|
||||
/// the serving node — to churn whenever any member's app went online or
|
||||
/// offline. Freshness still gates *routing* (see `availability_from_events`):
|
||||
/// stale nodes are never selected as serve targets. Revocation is unaffected:
|
||||
/// a member removed from the NIP-43 roster leaves the intersection at the next
|
||||
/// roster poll regardless of how fresh their last status is.
|
||||
pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec<String> {
|
||||
let Some(members) = latest_membership_list(events) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = nostr::Timestamp::now().as_secs();
|
||||
let mut ids: Vec<String> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind.as_u16() as u64 == MESH_STATUS_KIND && status_is_fresh(event, now)
|
||||
})
|
||||
.filter(|event| event.kind.as_u16() as u64 == MESH_STATUS_KIND)
|
||||
.filter(|event| {
|
||||
reporter_pubkey_from_status_event(event)
|
||||
.is_some_and(|reporter| members.contains(&reporter.to_ascii_lowercase()))
|
||||
@@ -79,6 +86,13 @@ fn latest_membership_list(events: &[nostr::Event]) -> Option<BTreeSet<String>> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn current_member_pubkeys(events: &[nostr::Event]) -> Vec<String> {
|
||||
latest_membership_list(events)
|
||||
.map(BTreeSet::into_iter)
|
||||
.map(Iterator::collect)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
|
||||
let content = serde_json::from_str::<serde_json::Value>(&event.content).ok()?;
|
||||
let owner_id = content
|
||||
@@ -242,11 +256,19 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Status filter for admission and availability queries.
|
||||
///
|
||||
/// Deliberately has no `since` bound: status events are parameterized
|
||||
/// replaceable (one per member), and admission must see a member's latest
|
||||
/// owner binding even when that member has been offline for longer than
|
||||
/// [`STATUS_FRESHNESS_SECS`]. Freshness is applied *after* the query, and only
|
||||
/// where it belongs — routing (`availability_from_events`), never admission
|
||||
/// (`owner_ids_from_events`).
|
||||
pub fn mesh_status_filter() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kinds": [MESH_STATUS_KIND],
|
||||
"#k": ["buzz-mesh-status"],
|
||||
"limit": 100
|
||||
"limit": MESH_STATUS_PAGE_SIZE
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod discovery;
|
||||
pub use discovery::{
|
||||
availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter,
|
||||
};
|
||||
pub(crate) use discovery::{current_member_pubkeys, MESH_STATUS_PAGE_SIZE};
|
||||
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
|
||||
|
||||
mod catalog;
|
||||
|
||||
@@ -388,20 +388,45 @@ fn signed_reporter_target(reporter_secret: &str, model: &str, endpoint: &str) ->
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_status_is_excluded_from_admission_and_availability() {
|
||||
fn stale_status_keeps_member_admitted_but_excluded_from_routing() {
|
||||
let secret = "8".repeat(64);
|
||||
let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex();
|
||||
let stale = signed_reporter_target_at(&secret, "stale-model", &test_endpoint_token(), 1_000);
|
||||
// No newer event is required to age this status out. Freshness is measured
|
||||
// against wall clock, so an entirely offline mesh cannot remain live forever.
|
||||
// Membership is the trust boundary: a current member whose device went
|
||||
// offline (stale status) must stay admitted, otherwise every app
|
||||
// open/close in the community churns the allowlist and restarts serving
|
||||
// nodes. Freshness still gates routing: a stale node is never selected as
|
||||
// a serve target.
|
||||
let membership = signed_membership_event_at(std::slice::from_ref(&member), 900);
|
||||
let events = vec![stale, membership];
|
||||
|
||||
assert!(super::owner_ids_from_events(&events).is_empty());
|
||||
assert_eq!(super::owner_ids_from_events(&events).len(), 1);
|
||||
let availability = super::availability_from_events(events);
|
||||
assert!(availability.serve_targets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_member_is_dropped_from_admission_despite_fresh_status() {
|
||||
// Revocation path: freshness must never resurrect trust. A reporter with a
|
||||
// perfectly fresh status who is absent from the latest NIP-43 roster gets
|
||||
// no admission entry.
|
||||
let member_secret = "8".repeat(64);
|
||||
let outsider_secret = "9".repeat(64);
|
||||
let member = nostr::Keys::parse(&member_secret)
|
||||
.unwrap()
|
||||
.public_key()
|
||||
.to_hex();
|
||||
let now = nostr::Timestamp::now().as_secs();
|
||||
let fresh_outsider =
|
||||
signed_reporter_target_at(&outsider_secret, "model", &test_endpoint_token(), now);
|
||||
// Latest roster lists only `member`; the outsider was removed (or never
|
||||
// admitted).
|
||||
let membership = signed_membership_event_at(std::slice::from_ref(&member), now);
|
||||
let events = vec![fresh_outsider, membership];
|
||||
|
||||
assert!(super::owner_ids_from_events(&events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_endpoint_can_advertise_multiple_models() {
|
||||
let secret = "a".repeat(64);
|
||||
|
||||
@@ -415,16 +415,19 @@ function CatalogPicker({
|
||||
selected: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
// Collapsed: recommended + the next few viable entries. Expanded: all.
|
||||
const visible = expanded ? catalog.entries : catalog.entries.slice(0, 4);
|
||||
// Above the fold: the Buzz-curated picks (models known to work well with
|
||||
// agents on shared compute). Below: everything else, as advanced options.
|
||||
const curated = catalog.entries.filter((e) => e.curated);
|
||||
const advanced = catalog.entries.filter((e) => !e.curated);
|
||||
const visible = expanded ? catalog.entries : curated;
|
||||
return (
|
||||
<div className="mt-1" data-testid="mesh-share-compute-catalog">
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
Suggested for this machine
|
||||
Recommended for this machine
|
||||
{catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("}
|
||||
{catalog.vramDisplay} AI memory):
|
||||
</p>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
<ul className="mt-1.5 flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||
{visible.map((entry) => {
|
||||
const isSelected = entry.name === selected;
|
||||
const tooLarge = entry.fit === "too_large";
|
||||
@@ -468,15 +471,16 @@ function CatalogPicker({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{catalog.entries.length > visible.length || expanded ? (
|
||||
{advanced.length > 0 ? (
|
||||
<button
|
||||
className="mt-1 text-sm text-muted-foreground underline hover:text-foreground"
|
||||
data-testid="mesh-catalog-advanced-toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{expanded
|
||||
? "Show fewer"
|
||||
: `Show all ${catalog.entries.length} models`}
|
||||
? "Hide advanced models"
|
||||
: `Advanced: ${advanced.length} more models`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,11 @@ export type MeshCatalogEntry = {
|
||||
fit: MeshModelFit;
|
||||
installed: boolean;
|
||||
recommended: boolean;
|
||||
/**
|
||||
* Buzz-curated pick — known to survive the agent harness. Curated entries
|
||||
* render above the fold; everything else is "advanced".
|
||||
*/
|
||||
curated: boolean;
|
||||
};
|
||||
|
||||
export type MeshModelCatalog = {
|
||||
@@ -75,7 +80,7 @@ export type MeshModelCatalog = {
|
||||
vramDisplay: string;
|
||||
vramGb: number;
|
||||
recommended: string | null;
|
||||
/** Ranked: recommended first, then by fit, then larger first within a fit. */
|
||||
/** Ranked: recommended first, then curated, then by fit, larger first. */
|
||||
entries: MeshCatalogEntry[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user