mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Stack: [#5112](https://github.com/block/buzz/pull/5112) → this PR Stacks on the team catalog backend PR (#5112). Contains the `desktop/**` changes that turn the add-agent surface into a single Community Catalog browsing both shared agents and shared teams. ## Owner catalog sync pipeline `usePersonaSync.ts` gains a hydration pipeline in `startPersonaSync` so a fresh device that comes online after another already published the owner's 30178 catalog head reconstructs the complete state without falsely retracting it. The owner's persona/team/managed-agent/30178/deletion history is backfilled up front (a live-only subscription gets no history — reconnect-replay's since-cursor is undefined until the first live event), then a live subscription takes over. Four properties keep a fresh sync from purging the owner's valid shared head: - **Paged backfill with a safe termination guarantee.** The relay serves each REQ newest-first and clamps `limit` to its advertised `max_limit`, so a large owner's history overflows one page — a newer 30178/30176 could return in-page while an older required 30175 constituent falls beyond it. `fetchOwnerHistoryToExhaustion` pages the full window with the `until` time cursor (the only cursor the WS REQ filter exposes; the DB `before_id` keyset is REST-only), deduping the inclusive-boundary rows. A short page terminates normally. A full page whose oldest event cannot advance the time-only cursor is a **dense boundary** — more than one page of events share one `created_at` second, which the WS filter has no `(created_at, id)` cursor to escape — and raises `PersonaHistoryDenseBoundaryError` rather than silently completing as if the history were exhausted and dropping the older constituents behind it. - **Constituents-before-catalog ordering.** Over the complete batch, `orderCatalogHeadsLast` stably defers every 30178 head past its 30175/30176 constituents. Reconciling a 30178 head before its personas hydrate makes the inbound team refresh fail member resolution and queue a dominating false tombstone; deferring the heads guarantees the constituents are all applied first, while newest-wins order within every other coordinate is untouched. - **Hydration boundary for concurrent live events.** The backfill fetch and the live subscription start concurrently into one reconcile chain. A live or replayed 30178 that arrives before the backfill reconciles its constituents would reproduce the same false-tombstone purge, so live events are buffered until the ordered backfill is dispatched and then drained in arrival order. Steady-state live events (after hydration completes) reconcile immediately. - **Explicit backfill failure policy.** A transient history-fetch rejection is retried with bounded backoff. When backfill cannot complete — retries exhausted, or a deterministic dense boundary — the pipeline enters a **degraded-live** state rather than leaving the subscription permanently inert with live events accumulating in the buffer: the hydration boundary still opens so buffered and future live events keep reconciling, but the whole catalog dependency set is dropped — the 30178 head, its 30175/30176 constituents, and any kind-5 deletion carrying a dependency-targeting `a` tag (classified by scanning *all* `a` tags, matching the backend's deletion router, which `find_map`s across every tag and routes the first signer-owned coordinate — so a malformed or foreign first `a` tag ahead of an owned 30176 cannot slip a destructive deletion through) — because backfill never fully hydrated the owner's constituents. Dropping only the 30178 head is not enough: the backend refreshes the catalog head after every team/persona save, and live delivery is newest-first, so a 30176 edit that adds a *new* member would reach the backend before that member's 30175 and falsely tombstone a witness-holding device's valid team. Holding the prior hydrated run's constituents on disk only proves the *old* revision is resolvable — it says nothing about a new member — which is why the entire dependency set is held rather than just the head. 30177 managed-agent runtime policy stays live (it drives no catalog refresh). A degraded device stays stale on team/persona edits until it self-heals on the next effect re-run (restart, or an identity/community switch) — the correct trade against destroying valid shared state. ## Data layer Relay paging, signature verification, NIP-33 head selection, and untrusted-content parsing for the kind `30178` team catalog live natively in the `fetch_team_catalog` Tauri command (`team_catalog.rs`), structurally mirroring `fetch_persona_catalog` (`persona_catalog.rs`). A catalog refresh crosses IPC once and never verifies a signature on the webview thread. `teamCatalogRelay.ts` is now a thin presentation and local-linkage layer over the verified projection — it shapes entries for display and links each to a local team, and never parses or verifies. Parsing is all-or-nothing, identical to the add-time re-fetch in `add_team_from_catalog`: a team with any invalid member fails to parse and the publication is dropped from the catalog, matching persona behavior. **Behavior delta:** the previously reviewed partial-render of invalid-member teams — a warning banner on an entry that could never be added — is removed. Invalid publications are dropped entirely rather than surfaced as un-addable. ## Hooks `useTeamCatalogRelay.ts` mirrors the persona catalog hook: a community-keyed query over `fetch_team_catalog`, live invalidation on kind `30178`, share/unshare, and add-from-catalog (which invalidates both the teams and personas stores, since adopting a team copies its members as local personas). ## CommunityCatalogDialog Single unified surface replacing the former separate dialogs. Agents and Teams appear as labeled sections with type-tagged selection and a teams-preferred launch. `TeamsSection`'s discover entry and the new-agent card both open this one dialog. `PersonaCatalogDialog.tsx` is removed; persona browsing now lives inside the unified dialog. ## TeamShareDialog Publishes and unshares team catalog entries via `set_team_shared`. ## e2e + screenshots `team-catalog.spec.ts` covers the browse + adopt flow; `team-catalog-screenshots.spec.ts` produces the pixel-regression set. `e2eBridge.ts` gains `mockTeamCatalogPublications`, which mirrors the native command's head selection and shared gate and performs only a shallow `v`/`name`/`members`-array shape check; per-member validation stays in the Rust command. ## Follow-ups None. The shared agent-definition text-safety policy (Unicode-control/bidi/zero-width rejection) already covers the team surface: #5112's `validate_team_catalog_content` chokepoint gates every field delivered verbatim to the harness on both the publish and adopt paths, and this PR's parse layer consumes that verified projection rather than re-validating on the webview thread. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
//! Native team-catalog fetch and trust-boundary projection.
|
|
//!
|
|
//! The renderer owns presentation/linkage to local teams. Relay paging,
|
|
//! signature verification, NIP-33 head selection, and untrusted-content
|
|
//! parsing stay here — structurally the persona-catalog equivalent
|
|
//! (`persona_catalog.rs`) with kind 30178 and the team content parser swapped
|
|
//! in, so a catalog refresh crosses IPC once and never verifies a signature on
|
|
//! the webview thread.
|
|
//!
|
|
//! Content parsing reuses `managed_agents::team_catalog::team_catalog_content_from_event`
|
|
//! — the same all-or-nothing parse `add_team_from_catalog` re-runs at add time,
|
|
//! so a head this command projects is exactly a head the backend will accept.
|
|
|
|
use std::{collections::HashMap, time::Duration};
|
|
|
|
use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG};
|
|
use nostr::Event;
|
|
use serde::Serialize;
|
|
use tauri::State;
|
|
|
|
use crate::{
|
|
app_state::AppState,
|
|
managed_agents::team_catalog::{team_catalog_content_from_event, TeamCatalogContent},
|
|
native_relay_client::NativeRelayClient,
|
|
};
|
|
|
|
const CATALOG_PAGE_SIZE: usize = 500;
|
|
const MAX_CATALOG_PAGES: usize = 40;
|
|
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[derive(Debug, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct TeamCatalogPublication {
|
|
event_id: String,
|
|
owner_pubkey: String,
|
|
team_d_tag: String,
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
instructions: Option<String>,
|
|
members: Vec<TeamCatalogMemberProjection>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TeamCatalogMemberProjection {
|
|
member_key: String,
|
|
display_name: String,
|
|
system_prompt: String,
|
|
avatar_url: Option<String>,
|
|
runtime: Option<String>,
|
|
model: Option<String>,
|
|
provider: Option<String>,
|
|
}
|
|
|
|
/// Fetches the active community's relay-confirmed team catalog.
|
|
///
|
|
/// The command accepts no relay or identity input: both are snapshotted from
|
|
/// `AppState`, then checked again before return so an in-flight old-community
|
|
/// response cannot populate the new community's query cache.
|
|
#[tauri::command]
|
|
pub(crate) async fn fetch_team_catalog(
|
|
state: State<'_, AppState>,
|
|
relay_client: State<'_, NativeRelayClient>,
|
|
) -> Result<Vec<TeamCatalogPublication>, String> {
|
|
let keys = state.signing_keys()?;
|
|
let owner = keys.public_key().to_hex();
|
|
let relay_url = crate::relay::relay_ws_url_with_override(&state);
|
|
let session = relay_client.session(relay_url.clone(), keys).await;
|
|
let by_id = collect_verified_catalog(|until| {
|
|
let session = &session;
|
|
async move {
|
|
let mut filter = serde_json::json!({
|
|
"kinds": [KIND_TEAM_CATALOG],
|
|
"limit": CATALOG_PAGE_SIZE,
|
|
});
|
|
if let Some(until) = until {
|
|
filter["until"] = serde_json::json!(until);
|
|
}
|
|
let page = session.fetch_events(filter, PAGE_TIMEOUT).await?;
|
|
let page_len = page.len();
|
|
// Schnorr verification is CPU-bound. Keep the complete page off the
|
|
// async executor (and therefore off Tauri command scheduling).
|
|
let verified = tauri::async_runtime::spawn_blocking(move || verify_page(page))
|
|
.await
|
|
.map_err(|error| format!("catalog signature verification failed: {error}"))?;
|
|
Ok((page_len, verified))
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
let current_keys = state.signing_keys()?;
|
|
if current_keys.public_key().to_hex() != owner
|
|
|| crate::relay::relay_ws_url_with_override(&state) != relay_url
|
|
{
|
|
return Err("team catalog scope changed while fetching".to_string());
|
|
}
|
|
|
|
Ok(publications_from_verified_events(
|
|
by_id.into_values().collect(),
|
|
))
|
|
}
|
|
|
|
/// Page the catalog to exhaustion through `fetch_verified_page`, which returns
|
|
/// the wire page length and the signature-verified events for a given inclusive
|
|
/// `until` cursor. Kept generic over the fetcher so the paging/termination logic
|
|
/// is driven by the exact same code in production and in the cap-boundary
|
|
/// regressions, with no relay or Tauri state.
|
|
///
|
|
/// Exhaustion is proven ONLY by a `Done` (a short page). If the page budget runs
|
|
/// out while pages are still full and advancing, the catalog is larger than
|
|
/// `MAX_CATALOG_PAGES` can walk: returning the collected heads would silently
|
|
/// present a truncated catalog as complete, so fail loudly instead — the same
|
|
/// degrade philosophy as `DenseBoundary`/`NoVerifiedEvents`, surfaced by the
|
|
/// browse dialog as an error rather than a silently short list.
|
|
async fn collect_verified_catalog<F, Fut>(
|
|
mut fetch_verified_page: F,
|
|
) -> Result<HashMap<String, Event>, String>
|
|
where
|
|
F: FnMut(Option<u64>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<(usize, Vec<Event>), String>>,
|
|
{
|
|
let mut by_id = HashMap::new();
|
|
let mut until = None;
|
|
let mut exhausted = false;
|
|
|
|
for _ in 0..MAX_CATALOG_PAGES {
|
|
let (page_len, verified) = fetch_verified_page(until).await?;
|
|
match merge_verified_page(&mut by_id, page_len, until, verified) {
|
|
PageProgress::Done => {
|
|
exhausted = true;
|
|
break;
|
|
}
|
|
PageProgress::Next(next_until) => until = Some(next_until),
|
|
// The relay filter exposes no `(created_at, id)` cursor to page
|
|
// within a second, so more than one page at the boundary second
|
|
// cannot be paged past. Fail loudly rather than project a truncated
|
|
// catalog as complete — the browse dialog surfaces this as an error
|
|
// instead of silently dropping every older team.
|
|
PageProgress::DenseBoundary(second) => {
|
|
return Err(format!(
|
|
"team catalog has more than one page of events at created_at {second}; \
|
|
the time-only relay cursor cannot page past it"
|
|
));
|
|
}
|
|
// A full page with no verifiable events cannot advance the cursor on
|
|
// trusted data. Advancing on the wire timestamp would let one forged
|
|
// `created_at` warp the cursor past — and silently drop — every valid
|
|
// team below it, so fail loudly instead.
|
|
PageProgress::NoVerifiedEvents => {
|
|
return Err(
|
|
"team catalog returned a full page with no verifiable events; \
|
|
cannot safely advance the cursor"
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !exhausted {
|
|
return Err(format!(
|
|
"team catalog exceeds the {MAX_CATALOG_PAGES} page fetch budget \
|
|
({CATALOG_PAGE_SIZE} events per page); cannot list it completely"
|
|
));
|
|
}
|
|
|
|
Ok(by_id)
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
enum PageProgress {
|
|
Done,
|
|
Next(u64),
|
|
/// A full page whose oldest verified timestamp cannot drop the inclusive
|
|
/// `until` cursor: more than one page of events shares this second, and the
|
|
/// relay filter has no `(created_at, id)` cursor to escape it.
|
|
DenseBoundary(u64),
|
|
/// A full page with no verifiable events. The cursor can only advance on
|
|
/// trusted timestamps, so there is nothing safe to page with.
|
|
NoVerifiedEvents,
|
|
}
|
|
|
|
/// Retain only events whose Schnorr signature verifies. This is the single
|
|
/// trust gate for a relay page: paging, head selection, and content parsing all
|
|
/// run on its output, so a forged or tampered event never influences the
|
|
/// cursor or the projected catalog. Shared with the paging regression so the
|
|
/// test drives the exact seam production does, not a stubbed result.
|
|
fn verify_page(page: Vec<Event>) -> Vec<Event> {
|
|
page.into_iter()
|
|
.filter(|event| event.verify().is_ok())
|
|
.collect()
|
|
}
|
|
|
|
fn merge_verified_page(
|
|
by_id: &mut HashMap<String, Event>,
|
|
wire_page_len: usize,
|
|
until: Option<u64>,
|
|
verified: Vec<Event>,
|
|
) -> PageProgress {
|
|
// The oldest *verified* timestamp is the only value safe to page with: an
|
|
// unverifiable event must never control the cursor, or one forged
|
|
// `created_at` (e.g. 0) would warp `until` past — and silently drop — every
|
|
// valid team below it. Captured before the page is drained into `by_id`.
|
|
let verified_oldest = verified
|
|
.iter()
|
|
.map(|event| event.created_at.as_secs())
|
|
.min();
|
|
|
|
for event in verified {
|
|
by_id.insert(event.id.to_hex(), event);
|
|
}
|
|
|
|
// A short page is the end of the catalog.
|
|
if wire_page_len < CATALOG_PAGE_SIZE {
|
|
return PageProgress::Done;
|
|
}
|
|
|
|
// A full page must advance on a verified timestamp. With none, the cursor
|
|
// cannot move safely — fail loudly rather than trust the wire or complete.
|
|
let Some(oldest) = verified_oldest else {
|
|
return PageProgress::NoVerifiedEvents;
|
|
};
|
|
// When the oldest verified timestamp cannot drop below the current inclusive
|
|
// `until`, the page is stuck at a dense boundary second: silently stopping
|
|
// would drop every older team and falsely report the catalog exhausted.
|
|
if until.is_some_and(|until| oldest >= until) {
|
|
return PageProgress::DenseBoundary(oldest);
|
|
}
|
|
PageProgress::Next(oldest)
|
|
}
|
|
|
|
fn publications_from_verified_events(mut events: Vec<Event>) -> Vec<TeamCatalogPublication> {
|
|
events.sort_by(|left, right| {
|
|
right
|
|
.created_at
|
|
.cmp(&left.created_at)
|
|
.then_with(|| left.id.cmp(&right.id))
|
|
});
|
|
let mut claimed = std::collections::HashSet::new();
|
|
let mut publications = Vec::new();
|
|
|
|
for event in events {
|
|
if event.kind.as_u16() as u32 != KIND_TEAM_CATALOG {
|
|
continue;
|
|
}
|
|
let Some(team_d_tag) = single_tag(&event, "d") else {
|
|
continue;
|
|
};
|
|
if team_d_tag.is_empty() {
|
|
continue;
|
|
}
|
|
let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase();
|
|
let coordinate = (owner_pubkey.clone(), team_d_tag.clone());
|
|
if !claimed.insert(coordinate) {
|
|
continue;
|
|
}
|
|
|
|
// Claim happens before visibility or parsing. A valid newest unshared
|
|
// or malformed head is still the NIP-33 head and must not resurrect an
|
|
// older shared definition.
|
|
if !event_is_shared(&event) {
|
|
continue;
|
|
}
|
|
// All-or-nothing parse, identical to the add-time re-fetch: a team with
|
|
// any invalid member cannot be adopted, so a partial projection would
|
|
// only offer an un-addable entry.
|
|
let Ok(content) = team_catalog_content_from_event(&event) else {
|
|
continue;
|
|
};
|
|
publications.push(publication(
|
|
event.id.to_hex(),
|
|
owner_pubkey,
|
|
team_d_tag,
|
|
content,
|
|
));
|
|
}
|
|
publications
|
|
}
|
|
|
|
fn publication(
|
|
event_id: String,
|
|
owner_pubkey: String,
|
|
team_d_tag: String,
|
|
content: TeamCatalogContent,
|
|
) -> TeamCatalogPublication {
|
|
TeamCatalogPublication {
|
|
event_id,
|
|
owner_pubkey,
|
|
team_d_tag,
|
|
name: content.name,
|
|
description: content.description,
|
|
instructions: content.instructions,
|
|
members: content
|
|
.members
|
|
.into_iter()
|
|
.map(|member| TeamCatalogMemberProjection {
|
|
member_key: member.member_key,
|
|
display_name: member.display_name,
|
|
system_prompt: member.system_prompt.unwrap_or_default(),
|
|
avatar_url: member.avatar_url,
|
|
runtime: member.runtime,
|
|
model: member.model,
|
|
provider: member.provider,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
/// A tag's value, but only when the event carries exactly one of that tag.
|
|
///
|
|
/// Ambiguity is absence: the relay admits exactly one bounded `d` tag, so a
|
|
/// multi-`d` event is malformed and picking the first would resolve a
|
|
/// different coordinate than the publisher addressed.
|
|
fn single_tag(event: &Event, name: &str) -> Option<String> {
|
|
let matches = event
|
|
.tags
|
|
.iter()
|
|
.filter_map(|tag| {
|
|
let values = tag.as_slice();
|
|
(values.len() >= 2 && values.first().is_some_and(|value| value == name))
|
|
.then(|| values[1].clone())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
(matches.len() == 1).then(|| matches[0].clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "team_catalog_tests.rs"]
|
|
mod tests;
|