Skip to content

Commit a7452bb

Browse files
Duncanwpfleger96
andcommitted
feat(team-catalog): add community catalog for agents and teams
Extend the unified add-agent dialog (#5015) into a single Community Catalog surface that browses both shared agents and shared teams. The dialog keeps sections for personas and teams with type-tagged selection and a teams-preferred launch. TeamsSection's discover entry and the new-agent card both open this one dialog. 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. 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. Parsing is all-or-nothing, identical to the add-time re-fetch: a team with any invalid member fails to parse and is dropped from the catalog, matching persona behavior. The prior partial-render of invalid-member teams (a warning banner on an entry that could never be added) is removed. Instruction review renders verbatim in a <pre> on all three surfaces so the text a user reviews is the text sent to the agent. TeamShareDialog publishes and unshares team catalog entries. Playwright e2e covers the unified create/catalog/import navigation, the teams catalog flow, and the screenshot regression set. usePersonaSync now subscribes to kind 30178 (both backfill and live sub) so a second device retains the owner's own team catalog head as a publication witness — without it that device never learns another device published, and its later edit or delete cannot supersede or retract the discoverable head. The head carries no local record; the backend (#5112) retains it and drives supersede/retract. On a fresh device's first sync the startup backfill orders catalog heads last (orderCatalogHeadsLast): the relay serves history newest-first, so a freshly shared 30178 head would otherwise reconcile before the personas/team it projects — the backend's team refresh would then resolve against an empty roster and retract the owner's valid head with a dominating false tombstone. Deferring catalog heads past their constituents preserves newest-wins within every other coordinate; live events, arriving singly, are unaffected. usePersonaSync.test.mjs asserts the expanded kind set and the backfill ordering. Stack: #5112 -> this PR Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 1480db4 commit a7452bb

32 files changed

Lines changed: 3543 additions & 786 deletions

desktop/playwright.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export default defineConfig({
158158
"**/huddle-transcription.spec.ts",
159159
"**/agent-numeric-tuning.spec.ts",
160160
"**/needs-restart-screenshots.spec.ts",
161+
"**/team-catalog-screenshots.spec.ts",
161162
],
162163
use: {
163164
...devices["Desktop Chrome"],
@@ -179,6 +180,7 @@ export default defineConfig({
179180
"**/persona-env-vars.spec.ts",
180181
"**/persona-sync.spec.ts",
181182
"**/team-snapshot.spec.ts",
183+
"**/team-catalog.spec.ts",
182184
"**/agents-everywhere.live.spec.ts",
183185
"**/relay-restart.live.spec.ts",
184186
"**/parity-ancestor-island.spec.ts",

desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod relay_admission;
4141
mod reset;
4242
mod secret_store;
4343
mod shutdown;
44+
mod team_catalog;
4445
mod templates;
4546
mod terminal_runtime;
4647
#[cfg_attr(not(test), allow(dead_code))]
@@ -720,6 +721,7 @@ pub fn run() {
720721
discover_backend_providers,
721722
probe_backend_provider,
722723
persona_catalog::fetch_persona_catalog,
724+
team_catalog::fetch_team_catalog,
723725
unread_catch_up::unread_catch_up,
724726
observed_unread::observed_unread_open_scope,
725727
observed_unread::observed_unread_ingest,
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
//! Native team-catalog fetch and trust-boundary projection.
2+
//!
3+
//! The renderer owns presentation/linkage to local teams. Relay paging,
4+
//! signature verification, NIP-33 head selection, and untrusted-content
5+
//! parsing stay here — structurally the persona-catalog equivalent
6+
//! (`persona_catalog.rs`) with kind 30178 and the team content parser swapped
7+
//! in, so a catalog refresh crosses IPC once and never verifies a signature on
8+
//! the webview thread.
9+
//!
10+
//! Content parsing reuses `managed_agents::team_catalog::team_catalog_content_from_event`
11+
//! — the same all-or-nothing parse `add_team_from_catalog` re-runs at add time,
12+
//! so a head this command projects is exactly a head the backend will accept.
13+
14+
use std::{collections::HashMap, time::Duration};
15+
16+
use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG};
17+
use nostr::Event;
18+
use serde::Serialize;
19+
use tauri::State;
20+
21+
use crate::{
22+
app_state::AppState,
23+
managed_agents::team_catalog::{team_catalog_content_from_event, TeamCatalogContent},
24+
native_relay_client::NativeRelayClient,
25+
};
26+
27+
const CATALOG_PAGE_SIZE: usize = 500;
28+
const MAX_CATALOG_PAGES: usize = 40;
29+
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
30+
31+
#[derive(Debug, PartialEq, Serialize)]
32+
#[serde(rename_all = "camelCase")]
33+
pub(crate) struct TeamCatalogPublication {
34+
event_id: String,
35+
owner_pubkey: String,
36+
team_d_tag: String,
37+
name: String,
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
description: Option<String>,
40+
#[serde(skip_serializing_if = "Option::is_none")]
41+
instructions: Option<String>,
42+
members: Vec<TeamCatalogMemberProjection>,
43+
}
44+
45+
#[derive(Debug, PartialEq, Serialize)]
46+
#[serde(rename_all = "camelCase")]
47+
struct TeamCatalogMemberProjection {
48+
member_key: String,
49+
display_name: String,
50+
system_prompt: String,
51+
avatar_url: Option<String>,
52+
runtime: Option<String>,
53+
model: Option<String>,
54+
provider: Option<String>,
55+
}
56+
57+
/// Fetches the active community's relay-confirmed team catalog.
58+
///
59+
/// The command accepts no relay or identity input: both are snapshotted from
60+
/// `AppState`, then checked again before return so an in-flight old-community
61+
/// response cannot populate the new community's query cache.
62+
#[tauri::command]
63+
pub(crate) async fn fetch_team_catalog(
64+
state: State<'_, AppState>,
65+
relay_client: State<'_, NativeRelayClient>,
66+
) -> Result<Vec<TeamCatalogPublication>, String> {
67+
let keys = state.signing_keys()?;
68+
let owner = keys.public_key().to_hex();
69+
let relay_url = crate::relay::relay_ws_url_with_override(&state);
70+
let session = relay_client.session(relay_url.clone(), keys).await;
71+
let mut by_id = HashMap::new();
72+
let mut until = None;
73+
74+
for _ in 0..MAX_CATALOG_PAGES {
75+
let mut filter = serde_json::json!({
76+
"kinds": [KIND_TEAM_CATALOG],
77+
"limit": CATALOG_PAGE_SIZE,
78+
});
79+
if let Some(until) = until {
80+
filter["until"] = serde_json::json!(until);
81+
}
82+
let page = session.fetch_events(filter, PAGE_TIMEOUT).await?;
83+
let page_len = page.len();
84+
// Schnorr verification is CPU-bound. Keep the complete page off the
85+
// async executor (and therefore off Tauri command scheduling).
86+
let verified = tauri::async_runtime::spawn_blocking(move || {
87+
page.into_iter()
88+
.filter(|event| event.verify().is_ok())
89+
.collect::<Vec<_>>()
90+
})
91+
.await
92+
.map_err(|error| format!("catalog signature verification failed: {error}"))?;
93+
94+
let progress = merge_verified_page(&mut by_id, page_len, verified);
95+
match progress {
96+
PageProgress::Done => break,
97+
PageProgress::Next(next_until) => until = Some(next_until),
98+
}
99+
}
100+
101+
let current_keys = state.signing_keys()?;
102+
if current_keys.public_key().to_hex() != owner
103+
|| crate::relay::relay_ws_url_with_override(&state) != relay_url
104+
{
105+
return Err("team catalog scope changed while fetching".to_string());
106+
}
107+
108+
Ok(publications_from_verified_events(
109+
by_id.into_values().collect(),
110+
))
111+
}
112+
113+
#[derive(Debug, PartialEq)]
114+
enum PageProgress {
115+
Done,
116+
Next(u64),
117+
}
118+
119+
fn merge_verified_page(
120+
by_id: &mut HashMap<String, Event>,
121+
wire_page_len: usize,
122+
verified: Vec<Event>,
123+
) -> PageProgress {
124+
let size_before = by_id.len();
125+
let oldest = verified
126+
.iter()
127+
.map(|event| event.created_at.as_secs())
128+
.min();
129+
for event in verified {
130+
by_id.insert(event.id.to_hex(), event);
131+
}
132+
133+
// A short page is the end of the catalog; a page of only repeats means the
134+
// inclusive `until` cursor cannot advance past tied timestamps.
135+
if wire_page_len < CATALOG_PAGE_SIZE || by_id.len() == size_before {
136+
return PageProgress::Done;
137+
}
138+
// A full page of invalid signatures cannot supply a trusted cursor.
139+
oldest.map_or(PageProgress::Done, PageProgress::Next)
140+
}
141+
142+
fn publications_from_verified_events(mut events: Vec<Event>) -> Vec<TeamCatalogPublication> {
143+
events.sort_by(|left, right| {
144+
right
145+
.created_at
146+
.cmp(&left.created_at)
147+
.then_with(|| left.id.cmp(&right.id))
148+
});
149+
let mut claimed = std::collections::HashSet::new();
150+
let mut publications = Vec::new();
151+
152+
for event in events {
153+
if event.kind.as_u16() as u32 != KIND_TEAM_CATALOG {
154+
continue;
155+
}
156+
let Some(team_d_tag) = single_tag(&event, "d") else {
157+
continue;
158+
};
159+
if team_d_tag.is_empty() {
160+
continue;
161+
}
162+
let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase();
163+
let coordinate = (owner_pubkey.clone(), team_d_tag.clone());
164+
if !claimed.insert(coordinate) {
165+
continue;
166+
}
167+
168+
// Claim happens before visibility or parsing. A valid newest unshared
169+
// or malformed head is still the NIP-33 head and must not resurrect an
170+
// older shared definition.
171+
if !event_is_shared(&event) {
172+
continue;
173+
}
174+
// All-or-nothing parse, identical to the add-time re-fetch: a team with
175+
// any invalid member cannot be adopted, so a partial projection would
176+
// only offer an un-addable entry.
177+
let Ok(content) = team_catalog_content_from_event(&event) else {
178+
continue;
179+
};
180+
publications.push(publication(
181+
event.id.to_hex(),
182+
owner_pubkey,
183+
team_d_tag,
184+
content,
185+
));
186+
}
187+
publications
188+
}
189+
190+
fn publication(
191+
event_id: String,
192+
owner_pubkey: String,
193+
team_d_tag: String,
194+
content: TeamCatalogContent,
195+
) -> TeamCatalogPublication {
196+
TeamCatalogPublication {
197+
event_id,
198+
owner_pubkey,
199+
team_d_tag,
200+
name: content.name,
201+
description: content.description,
202+
instructions: content.instructions,
203+
members: content
204+
.members
205+
.into_iter()
206+
.map(|member| TeamCatalogMemberProjection {
207+
member_key: member.member_key,
208+
display_name: member.display_name,
209+
system_prompt: member.system_prompt.unwrap_or_default(),
210+
avatar_url: member.avatar_url,
211+
runtime: member.runtime,
212+
model: member.model,
213+
provider: member.provider,
214+
})
215+
.collect(),
216+
}
217+
}
218+
219+
/// A tag's value, but only when the event carries exactly one of that tag.
220+
///
221+
/// Ambiguity is absence: the relay admits exactly one bounded `d` tag, so a
222+
/// multi-`d` event is malformed and picking the first would resolve a
223+
/// different coordinate than the publisher addressed.
224+
fn single_tag(event: &Event, name: &str) -> Option<String> {
225+
let matches = event
226+
.tags
227+
.iter()
228+
.filter_map(|tag| {
229+
let values = tag.as_slice();
230+
(values.len() >= 2 && values.first().is_some_and(|value| value == name))
231+
.then(|| values[1].clone())
232+
})
233+
.collect::<Vec<_>>();
234+
(matches.len() == 1).then(|| matches[0].clone())
235+
}
236+
237+
#[cfg(test)]
238+
#[path = "team_catalog_tests.rs"]
239+
mod tests;

0 commit comments

Comments
 (0)