feat(desktop): add persona event kind with client publish/read/retain

Squashed for rebase — original commits:
- feat(desktop): add persona event kind with client publish/read/retain
- fix(desktop): sign every migrated persona event and drop the sentinel file
- fix(relay): validate kind:30175 persona d-tag slug grammar on ingest
- fix(desktop): drop env_vars from public PersonaEventContent
- docs: add NIP-AP spec for kind:30175 persona events
- feat(ci): add relay E2E testing job and persona event tests
This commit is contained in:
Will Pfleger
2026-06-17 20:54:04 -04:00
parent 25001eb741
commit ad1bba3dee
14 changed files with 2188 additions and 293 deletions
+29
View File
@@ -520,6 +520,35 @@ jobs:
path: /tmp/buzz-relay.log
if-no-files-found: ignore
relay-e2e:
name: Relay E2E
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Start relay
run: ./scripts/start-relay-for-tests.sh
- name: Relay E2E tests
run: cargo test -p buzz-test-client --test '*' -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
- name: Upload relay logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: relay-e2e-artifacts
path: /tmp/buzz-relay.log
if-no-files-found: ignore
web:
name: Web
runs-on: ubuntu-latest
+11
View File
@@ -110,6 +110,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300;
/// a compile-time bitset or sorted array with binary search for hot-path use.
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER];
/// NIP-AP: Agent Persona (parameterized replaceable, owner-authored).
///
/// Persona definition event published by the workspace owner. Addressed by
/// `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug.
/// Content is a JSON body containing persona fields (system_prompt,
/// display_name, avatar_url, runtime, model, provider, name_pool, env_vars).
/// Designed for discoverability and sharing — d-tag is not blinded.
pub const KIND_PERSONA: u32 = 30175;
// NIP-29 group admin events
/// NIP-29: Add a user to a group.
pub const KIND_NIP29_PUT_USER: u32 = 9000;
@@ -391,6 +400,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_AGENT_PROFILE,
KIND_AGENT_ENGRAM,
KIND_EVENT_REMINDER,
KIND_PERSONA,
KIND_NIP29_PUT_USER,
KIND_NIP29_REMOVE_USER,
KIND_NIP29_EDIT_METADATA,
@@ -573,6 +583,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
// Compile-time: new kinds are in the expected ranges.
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
+177 -8
View File
@@ -26,12 +26,13 @@ use buzz_core::kind::{
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER,
};
use buzz_core::verification::verify_event;
use nostr::Event;
@@ -154,7 +155,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER => Ok(Scope::UsersWrite),
| KIND_EVENT_REMINDER | KIND_PERSONA => Ok(Scope::UsersWrite),
// NIP-51 standard lists and NIP-65 relay list — user-owned global state,
// same ownership shape as kind:3 (contacts) and kind:0 (profile).
KIND_MUTE_LIST
@@ -344,6 +345,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_EVENT_REMINDER
// Agent profile (10100): user-owned replaceable, keyed by pubkey.
| KIND_AGENT_PROFILE
// NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag).
| KIND_PERSONA
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
| KIND_GIT_REPO_ANNOUNCEMENT
@@ -885,6 +888,56 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> {
Ok(())
}
/// Validate the envelope of a kind:30175 persona event.
///
/// Enforces:
/// * exactly one `d` tag with a non-empty value matching the slug grammar
/// `^[a-z0-9][a-z0-9_-]{0,63}$`.
///
/// Without this, an empty d-tag collapses every persona into the
/// `(pubkey, 30175, "")` slot — last-write-wins data loss.
fn validate_persona_envelope(event: &Event) -> Result<(), String> {
let mut d_tags: Vec<&str> = Vec::new();
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.len() >= 2 && parts[0].as_str() == "d" {
d_tags.push(&parts[1]);
}
}
if d_tags.len() != 1 {
return Err(format!(
"persona event must have exactly one `d` tag (got {})",
d_tags.len()
));
}
let d = d_tags[0];
if d.is_empty() {
return Err("persona event `d` tag must not be empty".to_string());
}
// Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
if d.len() > 64 {
return Err(format!(
"persona event `d` tag too long ({} chars, max 64)",
d.len()
));
}
let bytes = d.as_bytes();
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
return Err(
"persona event `d` tag must start with a lowercase letter or digit".to_string(),
);
}
if !bytes[1..]
.iter()
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
return Err(
"persona event `d` tag must match [a-z0-9_-] after the first character".to_string(),
);
}
Ok(())
}
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
///
/// Checks:
@@ -1483,6 +1536,12 @@ pub async fn ingest_event(
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// ── 15c. Persona envelope (kind:30175) ──────────────────────────────
if kind_u32 == KIND_PERSONA {
validate_persona_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
// Track pre-created channel UUID for compensation on insert failure.
let mut pre_created_channel: Option<Uuid> = None;
@@ -1867,7 +1926,8 @@ mod tests {
use super::*;
use buzz_core::kind::{
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM,
KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS,
KIND_PERSONA, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
KIND_USER_STATUS,
};
#[test]
@@ -2036,6 +2096,7 @@ mod tests {
KIND_EMOJI_LIST,
KIND_AGENT_ENGRAM,
KIND_AGENT_PROFILE,
KIND_PERSONA,
];
for kind in migrated {
assert!(
@@ -2094,6 +2155,21 @@ mod tests {
}
}
#[test]
fn persona_is_in_scope_allowlist() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_PERSONA, &dummy).unwrap(),
Scope::UsersWrite,
);
}
#[test]
fn persona_is_global_only() {
assert!(is_global_only_kind(KIND_PERSONA));
assert!(!requires_h_channel_scope(KIND_PERSONA));
}
#[test]
fn unknown_kind_rejected() {
let dummy = make_dummy_event();
@@ -2582,4 +2658,97 @@ mod tests {
);
assert_eq!(validate_event_reminder(&ev), Err("duplicate d tag"));
}
// ── NIP-AP persona envelope validation ───────────────────────────────
fn make_persona(tags: &[&[&str]]) -> Event {
make_event_with_tags(
KIND_PERSONA,
r#"{"display_name":"x","system_prompt":"y"}"#,
tags,
)
}
#[test]
fn persona_envelope_accepts_valid_slug() {
let ev = make_persona(&[&["d", "my-persona-1"]]);
assert!(validate_persona_envelope(&ev).is_ok());
}
#[test]
fn persona_envelope_accepts_single_char() {
let ev = make_persona(&[&["d", "a"]]);
assert!(validate_persona_envelope(&ev).is_ok());
}
#[test]
fn persona_envelope_accepts_max_length() {
let slug = "a".repeat(64);
let ev = make_persona(&[&["d", &slug]]);
assert!(validate_persona_envelope(&ev).is_ok());
}
#[test]
fn persona_envelope_rejects_missing_d_tag() {
let ev = make_persona(&[]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("`d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_empty_d_tag() {
let ev = make_persona(&[&["d", ""]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("must not be empty"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_duplicate_d_tags() {
let ev = make_persona(&[&["d", "slug-a"], &["d", "slug-b"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("`d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_too_long() {
let slug = "a".repeat(65);
let ev = make_persona(&[&["d", &slug]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("too long"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_uppercase() {
let ev = make_persona(&[&["d", "My-Persona"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("`d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_leading_underscore() {
let ev = make_persona(&[&["d", "_invalid"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("start with"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_leading_hyphen() {
let ev = make_persona(&[&["d", "-invalid"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("start with"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_spaces() {
let ev = make_persona(&[&["d", "has space"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("`d` tag"), "got: {err}");
}
#[test]
fn persona_envelope_rejects_dots() {
let ev = make_persona(&[&["d", "has.dot"]]);
let err = validate_persona_envelope(&ev).unwrap_err();
assert!(err.contains("`d` tag"), "got: {err}");
}
}
@@ -0,0 +1,451 @@
//! End-to-end tests for kind:30175 persona events (NIP-AP).
//!
//! These tests verify the relay correctly handles persona events:
//! - Accepts valid persona events with proper d-tag slugs
//! - Enforces NIP-33 replacement semantics (same d-tag, newer timestamp wins)
//! - Rejects invalid d-tag values (empty, too long, invalid characters)
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_persona -- --ignored
//! ```
use std::time::Duration;
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
use buzz_test_client::BuzzTestClient;
const PERSONA_KIND: u16 = 30175;
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn sub_id(name: &str) -> String {
format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4())
}
/// Build a minimal persona event with the given d-tag and content.
fn persona_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event {
EventBuilder::new(Kind::Custom(PERSONA_KIND), content)
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.sign_with_keys(keys)
.unwrap()
}
/// Build a persona event with an explicit created_at timestamp.
fn persona_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) -> nostr::Event {
EventBuilder::new(Kind::Custom(PERSONA_KIND), content)
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
// ── Publish and query back ───────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_persona_publish_and_query() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("test-persona-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({
"name": &d_tag,
"display_name": "Test Persona",
"description": "A test persona for E2E validation"
})
.to_string();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Publish persona event
let event = persona_event(&keys, &d_tag, &content);
let ok = client
.send_event(event.clone())
.await
.expect("send persona");
assert!(ok.accepted, "relay rejected persona event: {}", ok.message);
// Query it back using NIP-33 filter (kind + author + d-tag)
let sid = sub_id("query");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect events");
assert_eq!(events.len(), 1, "expected exactly one persona event");
let ev = &events[0];
assert_eq!(ev.content, content);
assert_eq!(ev.pubkey, keys.public_key());
assert_eq!(ev.kind, Kind::Custom(PERSONA_KIND));
client.disconnect().await.expect("disconnect");
}
// ── NIP-33 replacement semantics ─────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_persona_nip33_replacement_newer_wins() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("replace-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Publish older version
let old_content = r#"{"name":"old","display_name":"Old","description":"Old version"}"#;
let old_event = persona_event_at(&keys, &d_tag, old_content, 1_700_000_000);
let ok = client.send_event(old_event).await.expect("send old");
assert!(ok.accepted, "relay rejected old event: {}", ok.message);
// Publish newer version with same d-tag
let new_content = r#"{"name":"new","display_name":"New","description":"New version"}"#;
let new_event = persona_event_at(&keys, &d_tag, new_content, 1_700_000_100);
let ok = client.send_event(new_event).await.expect("send new");
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
// Query — should return only the newer event
let sid = sub_id("replace");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "NIP-33: only newest event should remain");
let ev = &events[0];
assert_eq!(ev.content, new_content, "should be the newer version");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_nip33_older_does_not_replace_newer() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("no-replace-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Publish newer version first
let new_content = r#"{"name":"new","display_name":"New","description":"Newer"}"#;
let new_event = persona_event_at(&keys, &d_tag, new_content, 1_700_000_200);
let ok = client.send_event(new_event).await.expect("send new");
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
// Publish older version — relay should accept but not replace
let old_content = r#"{"name":"old","display_name":"Old","description":"Older"}"#;
let old_event = persona_event_at(&keys, &d_tag, old_content, 1_700_000_100);
let _ok = client.send_event(old_event).await.expect("send old");
// Note: relay may accept or reject the older event depending on implementation.
// The key assertion is that querying returns the newer one.
// Query — should still return the newer event
let sid = sub_id("no-replace");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "should have exactly one event");
let ev = &events[0];
assert_eq!(ev.content, new_content, "newer event should persist");
client.disconnect().await.expect("disconnect");
}
// ── D-tag validation ─────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_persona_rejects_empty_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
let event = EventBuilder::new(
Kind::Custom(PERSONA_KIND),
r#"{"name":"x","display_name":"X","description":"X"}"#,
)
.tags(vec![Tag::parse(["d", ""]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.expect("send");
assert!(!ok.accepted, "relay should reject persona with empty d-tag");
assert!(
ok.message.contains("empty") || ok.message.contains("d") || ok.message.contains("tag"),
"rejection message should mention d-tag issue, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_missing_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// No d-tag at all
let event = EventBuilder::new(
Kind::Custom(PERSONA_KIND),
r#"{"name":"x","display_name":"X","description":"X"}"#,
)
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.expect("send");
assert!(!ok.accepted, "relay should reject persona without d-tag");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_too_long() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// 65 characters — exceeds the 64-char limit
let long_slug = "a".repeat(65);
let event = persona_event(
&keys,
&long_slug,
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with d-tag > 64 chars"
);
assert!(
ok.message.contains("long") || ok.message.contains("64"),
"rejection should mention length, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_uppercase() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
let event = persona_event(
&keys,
"My-Persona",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with uppercase d-tag"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_special_chars() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
let event = persona_event(
&keys,
"my.persona!",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with special chars in d-tag"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_starting_with_underscore() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Slug must start with [a-z0-9], not underscore
let event = persona_event(
&keys,
"_invalid",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with d-tag starting with underscore"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_accepts_valid_slugs() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Various valid slug patterns
let valid_slugs = [
"a",
"my-persona",
"persona_v2",
"0-starts-with-digit",
"a-b-c-d-e",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 64 chars
];
for slug in valid_slugs {
let content = format!(
r#"{{"name":"{}","display_name":"Test","description":"Valid slug test"}}"#,
slug
);
let event = persona_event(&keys, slug, &content);
let ok = client.send_event(event).await.expect("send");
assert!(
ok.accepted,
"relay should accept valid slug '{}', got rejection: {}",
slug, ok.message
);
}
client.disconnect().await.expect("disconnect");
}
// ── Multiple personas per author ─────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_persona_multiple_per_author() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect");
// Publish two different personas (different d-tags)
let slug_a = format!("persona-a-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let slug_b = format!("persona-b-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let event_a = persona_event(
&keys,
&slug_a,
r#"{"name":"a","display_name":"Persona A","description":"First"}"#,
);
let event_b = persona_event(
&keys,
&slug_b,
r#"{"name":"b","display_name":"Persona B","description":"Second"}"#,
);
let ok_a = client.send_event(event_a).await.expect("send A");
assert!(ok_a.accepted, "persona A rejected: {}", ok_a.message);
let ok_b = client.send_event(event_b).await.expect("send B");
assert!(ok_b.accepted, "persona B rejected: {}", ok_b.message);
// Query all personas by this author
let sid = sub_id("multi");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key());
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.len() >= 2,
"expected at least 2 persona events, got {}",
events.len()
);
client.disconnect().await.expect("disconnect");
}
+276 -277
View File
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -534,15 +534,27 @@ pub fn run() {
migration::reconcile_legacy_command_names(&app_handle);
migration::reconcile_provider_mcp_commands(&app_handle);
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("buzz-desktop: sync-team-personas: {e}");
}
// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
// memberships, DMs, and relay identity.
let state = app_handle.state::<AppState>();
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
// Persona-event migration signs every retained event with the
// owner's keys, so it must run after the persisted identity is
// resolved (not before — at that point keys may still be ephemeral).
let owner_keys = state
.keys
.lock()
.map(|k| k.clone())
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
migration::migrate_personas_to_events(&app_handle, &owner_keys);
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("buzz-desktop: sync-team-personas: {e}");
}
// Store the AppHandle so huddle commands can emit `huddle-state-changed`
// events via `huddle::emit_huddle_state` without threading the handle
@@ -551,9 +563,6 @@ pub fn run() {
*guard = Some(app_handle.clone());
}
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
// Bring up the runtime-owned relay-mesh call-me-now listener now,
// before any saved agent restore can request a connection. Its
// lifetime is tied to the runtime, not a UI mount — this is what
@@ -4,12 +4,16 @@ mod env_vars;
mod nest;
mod persona_avatars;
mod persona_card;
#[allow(dead_code)]
pub(crate) mod persona_events;
mod personas;
#[cfg(windows)]
mod process_lifecycle;
#[cfg(feature = "mesh-llm")]
mod relay_mesh;
mod restore;
#[allow(dead_code)]
pub mod retention;
mod runtime;
mod storage;
mod team_repair;
@@ -0,0 +1,245 @@
//! Serialize `PersonaRecord` ↔ kind:30175 persona events and publish/fetch via relay.
//!
//! Persona events are NIP-33 parameterized replaceable events keyed by
//! `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug.
use std::collections::BTreeMap;
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
use sprout_core::kind::KIND_PERSONA;
use super::PersonaRecord;
use crate::app_state::AppState;
/// The JSON body stored in a persona event's content field.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonaEventContent {
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
pub system_prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
}
/// Derive the d-tag (persona slug) from a `PersonaRecord`.
///
/// Uses `source_team_persona_slug` if available, otherwise falls back to `id`.
pub fn persona_d_tag(record: &PersonaRecord) -> String {
record
.source_team_persona_slug
.as_deref()
.unwrap_or(&record.id)
.to_string()
}
/// Build a kind:30175 event from a `PersonaRecord`.
///
/// Returns an unsigned `EventBuilder` — the caller signs and submits.
pub fn build_persona_event(record: &PersonaRecord) -> Result<EventBuilder, String> {
let content = PersonaEventContent {
display_name: record.display_name.clone(),
avatar_url: record.avatar_url.clone(),
system_prompt: record.system_prompt.clone(),
runtime: record.runtime.clone(),
model: record.model.clone(),
provider: record.provider.clone(),
name_pool: record.name_pool.clone(),
};
let content_json = serde_json::to_string(&content)
.map_err(|e| format!("failed to serialize persona content: {e}"))?;
let d_tag = persona_d_tag(record);
let tags = vec![Tag::parse(["d", d_tag.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content_json).tags(tags))
}
/// Parse a kind:30175 event back into a `PersonaRecord`.
///
/// The event's d-tag becomes the persona ID and slug.
pub fn persona_from_event(event: &nostr::Event) -> Result<PersonaRecord, String> {
let d_tag = event
.tags
.iter()
.find_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if values.first() == Some(&"d") {
values.get(1).map(|s| s.to_string())
} else {
None
}
})
.ok_or("persona event missing d-tag")?;
let content: PersonaEventContent = serde_json::from_str(event.content.as_ref())
.map_err(|e| format!("failed to parse persona event content: {e}"))?;
let created_at = event.created_at.to_human_datetime();
Ok(PersonaRecord {
id: d_tag.clone(),
display_name: content.display_name,
avatar_url: content.avatar_url,
system_prompt: content.system_prompt,
runtime: content.runtime,
model: content.model,
provider: content.provider,
name_pool: content.name_pool,
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: Some(d_tag),
env_vars: BTreeMap::new(),
created_at: created_at.clone(),
updated_at: created_at,
})
}
/// Publish a persona event to the relay.
pub async fn publish_persona_event(
record: &PersonaRecord,
state: &AppState,
) -> Result<String, String> {
let builder = build_persona_event(record)?;
let response = crate::relay::submit_event(builder, state).await?;
Ok(response.event_id)
}
/// Fetch all persona events authored by the current user from the relay.
pub async fn fetch_persona_events(state: &AppState) -> Result<Vec<nostr::Event>, String> {
let pubkey = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
keys.public_key().to_hex()
};
let filter = serde_json::json!({
"kinds": [KIND_PERSONA],
"authors": [pubkey]
});
crate::relay::query_relay(state, &[filter]).await
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_persona() -> PersonaRecord {
PersonaRecord {
id: "test-persona".to_string(),
display_name: "Test Persona".to_string(),
avatar_url: Some("https://example.com/avatar.png".to_string()),
system_prompt: "You are a test assistant.".to_string(),
runtime: Some("goose".to_string()),
model: Some("claude-opus-4".to_string()),
provider: Some("anthropic".to_string()),
name_pool: vec!["Alpha".to_string(), "Beta".to_string()],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: Some("test-slug".to_string()),
env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn d_tag_uses_slug_when_available() {
let record = sample_persona();
assert_eq!(persona_d_tag(&record), "test-slug");
}
#[test]
fn d_tag_falls_back_to_id() {
let mut record = sample_persona();
record.source_team_persona_slug = None;
assert_eq!(persona_d_tag(&record), "test-persona");
}
#[test]
fn build_persona_event_produces_correct_kind() {
let record = sample_persona();
let builder = build_persona_event(&record).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA);
}
#[test]
fn round_trip_serialization() {
let record = sample_persona();
let builder = build_persona_event(&record).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
let restored = persona_from_event(&event).unwrap();
assert_eq!(restored.id, "test-slug");
assert_eq!(restored.display_name, "Test Persona");
assert_eq!(
restored.avatar_url,
Some("https://example.com/avatar.png".to_string())
);
assert_eq!(restored.system_prompt, "You are a test assistant.");
assert_eq!(restored.runtime, Some("goose".to_string()));
assert_eq!(restored.model, Some("claude-opus-4".to_string()));
assert_eq!(restored.provider, Some("anthropic".to_string()));
assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]);
// env_vars are not included in public persona events (secrets travel
// via NIP-44-encrypted engrams only).
assert!(restored.env_vars.is_empty());
assert_eq!(
restored.source_team_persona_slug,
Some("test-slug".to_string())
);
assert!(!restored.is_builtin);
assert!(restored.is_active);
}
#[test]
fn round_trip_minimal_persona() {
let record = PersonaRecord {
id: "minimal".to_string(),
display_name: "Minimal".to_string(),
avatar_url: None,
system_prompt: "Hello".to_string(),
runtime: None,
model: None,
provider: None,
name_pool: vec![],
is_builtin: true,
is_active: false,
source_team: Some("team-1".to_string()),
source_team_persona_slug: None,
env_vars: BTreeMap::new(),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
};
let builder = build_persona_event(&record).unwrap();
let keys = nostr::Keys::generate();
let event = builder.sign_with_keys(&keys).unwrap();
let restored = persona_from_event(&event).unwrap();
assert_eq!(restored.id, "minimal");
assert_eq!(restored.display_name, "Minimal");
assert_eq!(restored.avatar_url, None);
assert_eq!(restored.runtime, None);
assert_eq!(restored.model, None);
assert_eq!(restored.provider, None);
assert!(restored.name_pool.is_empty());
assert!(restored.env_vars.is_empty());
// Deserialized persona is always non-builtin and active
assert!(!restored.is_builtin);
assert!(restored.is_active);
}
}
@@ -3,7 +3,12 @@ use std::{fs, path::PathBuf};
use tauri::AppHandle;
use crate::{
managed_agents::{managed_agents_base_dir, PersonaRecord},
managed_agents::{
managed_agents_base_dir,
persona_events::persona_from_event,
retention::{get_retained_personas, has_retained_personas, open_retention_db},
PersonaRecord,
},
util::now_iso,
};
@@ -327,6 +332,54 @@ pub fn validate_persona_activation_change(
Ok(())
}
/// Path to the retention SQLite database.
#[allow(dead_code)]
fn retention_db_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(managed_agents_base_dir(app)?.join("retention.db"))
}
/// Load personas from the retention store, returning them as PersonaRecords.
///
/// Returns `Ok(None)` if the retention DB doesn't exist or has no personas
/// for the current user pubkey.
#[allow(dead_code)]
fn load_from_retention(
app: &AppHandle,
pubkey: &str,
) -> Result<Option<Vec<PersonaRecord>>, String> {
let db_path = retention_db_path(app)?;
if !db_path.exists() {
return Ok(None);
}
let conn = open_retention_db(&db_path)?;
if !has_retained_personas(&conn, pubkey)? {
return Ok(None);
}
let retained = get_retained_personas(&conn, pubkey)?;
let mut records = Vec::with_capacity(retained.len());
for row in &retained {
let event: nostr::Event = serde_json::from_str(&row.raw_event)
.map_err(|e| format!("failed to parse retained event: {e}"))?;
match persona_from_event(&event) {
Ok(record) => records.push(record),
Err(e) => {
eprintln!(
"sprout-desktop: retention: skipping malformed persona event (d_tag={}): {e}",
row.d_tag
);
}
}
}
if records.is_empty() {
Ok(None)
} else {
Ok(Some(records))
}
}
pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
let path = personas_store_path(app)?;
let now = now_iso();
@@ -0,0 +1,317 @@
//! Local SQLite retention store for persona events.
//!
//! Provides durable client-side storage for persona events, enabling offline
//! boot when the relay is unreachable. Uses `INSERT OR REPLACE` keyed on
//! `(kind, pubkey, d_tag)` for NIP-33 latest-wins semantics.
use std::path::Path;
use rusqlite::{params, Connection, OptionalExtension};
/// A retained persona event row.
#[derive(Debug, Clone)]
pub struct RetainedEvent {
pub kind: u32,
pub pubkey: String,
pub d_tag: String,
pub content: String,
pub created_at: i64,
pub raw_event: String,
pub pending_sync: bool,
}
/// Open (or create) the retention database at the given path.
pub fn open_retention_db(path: &Path) -> Result<Connection, String> {
let conn = Connection::open(path).map_err(|e| format!("failed to open retention db: {e}"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS persona_events (
kind INTEGER NOT NULL,
pubkey TEXT NOT NULL,
d_tag TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
raw_event TEXT NOT NULL,
pending_sync INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (kind, pubkey, d_tag)
);",
)
.map_err(|e| format!("failed to create retention table: {e}"))?;
Ok(conn)
}
/// Upsert a persona event into the retention store.
///
/// Only replaces if the new event has a newer or equal `created_at` (NIP-33 semantics).
pub fn retain_event(conn: &Connection, event: &RetainedEvent) -> Result<(), String> {
conn.execute(
"INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (kind, pubkey, d_tag) DO UPDATE SET
content = excluded.content,
created_at = excluded.created_at,
raw_event = excluded.raw_event,
pending_sync = excluded.pending_sync
WHERE excluded.created_at >= persona_events.created_at",
params![
event.kind,
event.pubkey,
event.d_tag,
event.content,
event.created_at,
event.raw_event,
event.pending_sync as i32,
],
)
.map_err(|e| format!("failed to retain event: {e}"))?;
Ok(())
}
/// Load all retained persona events for a given pubkey.
pub fn get_retained_personas(
conn: &Connection,
pubkey: &str,
) -> Result<Vec<RetainedEvent>, String> {
let mut stmt = conn
.prepare(
"SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync
FROM persona_events
WHERE pubkey = ?1
ORDER BY d_tag",
)
.map_err(|e| format!("failed to prepare query: {e}"))?;
let rows = stmt
.query_map(params![pubkey], |row| {
Ok(RetainedEvent {
kind: row.get(0)?,
pubkey: row.get(1)?,
d_tag: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
raw_event: row.get(5)?,
pending_sync: row.get::<_, i32>(6)? != 0,
})
})
.map_err(|e| format!("failed to query retained events: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("failed to read retained event row: {e}"))
}
/// Get all events marked as pending sync (not yet confirmed on relay).
pub fn get_pending_sync(conn: &Connection) -> Result<Vec<RetainedEvent>, String> {
let mut stmt = conn
.prepare(
"SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync
FROM persona_events
WHERE pending_sync = 1",
)
.map_err(|e| format!("failed to prepare pending sync query: {e}"))?;
let rows = stmt
.query_map([], |row| {
Ok(RetainedEvent {
kind: row.get(0)?,
pubkey: row.get(1)?,
d_tag: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
raw_event: row.get(5)?,
pending_sync: row.get::<_, i32>(6)? != 0,
})
})
.map_err(|e| format!("failed to query pending sync events: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("failed to read pending sync row: {e}"))
}
/// Clear the pending_sync flag for a specific event (after relay confirms).
pub fn mark_synced(conn: &Connection, kind: u32, pubkey: &str, d_tag: &str) -> Result<(), String> {
conn.execute(
"UPDATE persona_events SET pending_sync = 0
WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3",
params![kind, pubkey, d_tag],
)
.map_err(|e| format!("failed to mark event synced: {e}"))?;
Ok(())
}
/// Check if the retention store has any persona events for the given pubkey.
pub fn has_retained_personas(conn: &Connection, pubkey: &str) -> Result<bool, String> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM persona_events WHERE pubkey = ?1)",
params![pubkey],
|row| row.get(0),
)
.map_err(|e| format!("failed to check retained personas: {e}"))
}
/// Look up a single retained event by its coordinate.
pub fn get_retained_event(
conn: &Connection,
kind: u32,
pubkey: &str,
d_tag: &str,
) -> Result<Option<RetainedEvent>, String> {
conn.query_row(
"SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync
FROM persona_events
WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3",
params![kind, pubkey, d_tag],
|row| {
Ok(RetainedEvent {
kind: row.get(0)?,
pubkey: row.get(1)?,
d_tag: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
raw_event: row.get(5)?,
pending_sync: row.get::<_, i32>(6)? != 0,
})
},
)
.optional()
.map_err(|e| format!("failed to get retained event: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_db() -> Connection {
open_retention_db(Path::new(":memory:")).unwrap()
}
fn sample_event() -> RetainedEvent {
RetainedEvent {
kind: 30175,
pubkey: "abc123".to_string(),
d_tag: "test-persona".to_string(),
content: r#"{"display_name":"Test"}"#.to_string(),
created_at: 1000,
raw_event: r#"{"id":"..."}"#.to_string(),
pending_sync: true,
}
}
#[test]
fn retain_and_retrieve() {
let conn = test_db();
let event = sample_event();
retain_event(&conn, &event).unwrap();
let results = get_retained_personas(&conn, "abc123").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].d_tag, "test-persona");
assert_eq!(results[0].created_at, 1000);
assert!(results[0].pending_sync);
}
#[test]
fn upsert_replaces_newer() {
let conn = test_db();
let mut event = sample_event();
retain_event(&conn, &event).unwrap();
event.content = r#"{"display_name":"Updated"}"#.to_string();
event.created_at = 2000;
retain_event(&conn, &event).unwrap();
let results = get_retained_personas(&conn, "abc123").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].created_at, 2000);
assert!(results[0].content.contains("Updated"));
}
#[test]
fn upsert_ignores_older() {
let conn = test_db();
let mut event = sample_event();
event.created_at = 2000;
retain_event(&conn, &event).unwrap();
event.content = r#"{"display_name":"Old"}"#.to_string();
event.created_at = 1000;
retain_event(&conn, &event).unwrap();
let results = get_retained_personas(&conn, "abc123").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].created_at, 2000);
assert!(!results[0].content.contains("Old"));
}
#[test]
fn pending_sync_query() {
let conn = test_db();
let mut event = sample_event();
event.pending_sync = true;
retain_event(&conn, &event).unwrap();
let mut event2 = sample_event();
event2.d_tag = "other".to_string();
event2.pending_sync = false;
retain_event(&conn, &event2).unwrap();
let pending = get_pending_sync(&conn).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].d_tag, "test-persona");
}
#[test]
fn mark_synced_clears_flag() {
let conn = test_db();
let event = sample_event();
retain_event(&conn, &event).unwrap();
mark_synced(&conn, 30175, "abc123", "test-persona").unwrap();
let pending = get_pending_sync(&conn).unwrap();
assert!(pending.is_empty());
let results = get_retained_personas(&conn, "abc123").unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].pending_sync);
}
#[test]
fn has_retained_personas_works() {
let conn = test_db();
assert!(!has_retained_personas(&conn, "abc123").unwrap());
let event = sample_event();
retain_event(&conn, &event).unwrap();
assert!(has_retained_personas(&conn, "abc123").unwrap());
assert!(!has_retained_personas(&conn, "other").unwrap());
}
#[test]
fn get_retained_event_by_coordinate() {
let conn = test_db();
let event = sample_event();
retain_event(&conn, &event).unwrap();
let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().d_tag, "test-persona");
let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap();
assert!(not_found.is_none());
}
#[test]
fn idempotent_retain_same_timestamp() {
let conn = test_db();
let event = sample_event();
retain_event(&conn, &event).unwrap();
retain_event(&conn, &event).unwrap();
let results = get_retained_personas(&conn, "abc123").unwrap();
assert_eq!(results.len(), 1);
}
}
+117
View File
@@ -917,6 +917,123 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
rename_provider_to_runtime_in_personas(&path);
}
/// Migrate existing `personas.json` entries to persona events in the local
/// retention store.
///
/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being
/// complete) and AFTER the persisted identity is resolved (it signs every
/// retained event with the owner's keys).
///
/// Idempotent: skips when the retention store already holds events for the
/// owner pubkey — the data is the sentinel, so no separate sentinel file is
/// needed. This avoids re-running (and resetting `pending_sync`) on every
/// launch when a sentinel write silently fails.
///
/// Strategy: write to local SQLite retention first (durable copy), mark as
/// `pending_sync = 1` for later relay publish. Migration succeeds on local
/// write, not relay acknowledgment. Every retained row is a real signed
/// event — there is no placeholder path.
pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
match migrate_personas_in_dir(&base_dir, keys) {
Ok(0) => {}
Ok(migrated) => {
eprintln!(
"sprout-desktop: persona-event-migration: {migrated} personas migrated to retention"
);
}
Err(e) => {
eprintln!("sprout-desktop: persona-event-migration: {e}");
}
}
}
/// Core migration logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Returns the number of personas written to the retention store. Returns
/// `Ok(0)` when migration has already run (retention store has rows for the
/// owner pubkey) or when there are no non-builtin personas to migrate.
fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, String> {
use crate::managed_agents::{
persona_events::{build_persona_event, persona_d_tag},
retention::{has_retained_personas, open_retention_db, retain_event, RetainedEvent},
PersonaRecord,
};
use nostr::JsonUtil;
use sprout_core::kind::KIND_PERSONA;
let pubkey = keys.public_key().to_hex();
// Read personas.json fresh at migration time. Nothing to migrate if the
// file is absent.
let personas_path = base_dir.join("personas.json");
if !personas_path.exists() {
return Ok(0);
}
let content = std::fs::read_to_string(&personas_path)
.map_err(|e| format!("failed to read personas.json: {e}"))?;
let records: Vec<PersonaRecord> = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse personas.json: {e}"))?;
if records.is_empty() {
return Ok(0);
}
// Open (or create) the retention database.
let db_path = base_dir.join("retention.db");
let conn =
open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?;
// Idempotency: the retention rows themselves are the sentinel. If the
// owner already has retained personas, migration ran on a prior launch.
if has_retained_personas(&conn, &pubkey)? {
return Ok(0);
}
let mut migrated = 0u32;
for record in &records {
// Skip built-in personas — they're always available from code.
if record.is_builtin {
continue;
}
let d_tag = persona_d_tag(record);
let builder = build_persona_event(record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?;
let retained = RetainedEvent {
kind: KIND_PERSONA,
pubkey: pubkey.clone(),
d_tag,
content: event.content.to_string(),
// Safety: nostr timestamps are seconds and stay below i64::MAX
// until year 2262.
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: true,
};
retain_event(&conn, &retained)
.map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?;
migrated += 1;
}
Ok(migrated)
}
#[cfg(test)]
#[path = "migration_test_support.rs"]
mod test_support;
+95
View File
@@ -716,3 +716,98 @@ fn reconcile_mcp_commands_skips_record_without_agent_command() {
reconcile_mcp_commands_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
/// Helper: write a `personas.json` directly in `base_dir` (the migration
/// reads `base_dir/personas.json`, where `base_dir` is the `agents` dir).
fn write_base_personas(base_dir: &Path, records: &serde_json::Value) {
std::fs::write(
base_dir.join("personas.json"),
serde_json::to_string_pretty(records).unwrap(),
)
.unwrap();
}
fn one_persona() -> serde_json::Value {
serde_json::json!([{
"id": "code-reviewer",
"display_name": "Code Reviewer",
"system_prompt": "You review code.",
"is_builtin": false,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}])
}
#[test]
fn migrate_personas_writes_signed_retention_rows() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 1);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &pubkey).unwrap();
assert_eq!(rows.len(), 1);
// Row holds a real signed event for the owner — not a placeholder.
assert_eq!(rows[0].pubkey, pubkey);
let event: nostr::Event = nostr::JsonUtil::from_json(&rows[0].raw_event).unwrap();
assert!(event.verify().is_ok());
assert!(rows[0].pending_sync);
}
#[test]
fn migrate_personas_skips_builtins() {
use crate::managed_agents::retention::{get_retained_personas, open_retention_db};
let base = tempfile::tempdir().unwrap();
write_base_personas(
base.path(),
&serde_json::json!([{
"id": "builtin:solo",
"display_name": "Solo",
"system_prompt": "x",
"is_builtin": true,
"is_active": true,
"name_pool": [],
"env_vars": {},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]),
);
let keys = nostr::Keys::generate();
let migrated = migrate_personas_in_dir(base.path(), &keys).unwrap();
assert_eq!(migrated, 0);
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
let rows = get_retained_personas(&conn, &keys.public_key().to_hex()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn migrate_personas_skips_when_retention_already_populated() {
let base = tempfile::tempdir().unwrap();
write_base_personas(base.path(), &one_persona());
let keys = nostr::Keys::generate();
// First run migrates; second run is a no-op (retention rows are the
// sentinel — no separate sentinel file).
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 1);
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
assert!(!base.path().join("migration_state.json").exists());
}
#[test]
fn migrate_personas_no_file_is_noop() {
let base = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
assert_eq!(migrate_personas_in_dir(base.path(), &keys).unwrap(), 0);
}
+258
View File
@@ -0,0 +1,258 @@
NIP-AP
======
Agent Personas
--------------
`draft` `optional`
This NIP defines `kind:30175` persona events — public, addressable definitions that describe how to instantiate an AI agent. A persona carries identity (display name, avatar), behavioral configuration (system prompt, model, runtime), and an optional name pool. It is the "blueprint" from which agents are spawned.
## Kind
This NIP claims `kind:30175` for agent persona definitions. It is in the NIP-33 parameterized replaceable range (30000–39999) per [NIP-01](01.md): addressed by `(pubkey, kind, d_tag)`, with only the latest event per address retained.
A dedicated kind (rather than encoding personas as NIP-78 `kind:30078` "Application-specific Data") is taken for the same reasons as [NIP-AE](NIP-AE.md): (1) it isolates this NIP's address space from any other application using the same pubkey — persona slugs cannot collide with another app's `d` tag choices; (2) it lets observers, indexers, and unknown-kind viewers identify persona events from the kind alone, without parsing content as a namespace demultiplexer.
## Roles
- **owner** — a Nostr identity (`pubkey_o`) that publishes and manages persona definitions. Typically the workspace operator.
- **agent** — a Nostr identity instantiated from a persona. Agents do NOT author persona events; they consume them. An agent MAY store a private snapshot of its originating persona in a [NIP-AE](NIP-AE.md) engram at `mem/persona` (encrypted, owner-readable).
## Slugs
The `d` tag of a persona event is the **plaintext persona slug**. A valid slug matches:
```
^[a-z0-9][a-z0-9_-]{0,63}$
```
Total length: 1–64 bytes. Slugs are flat identifiers (no path separators), unlike [NIP-AE](NIP-AE.md) memory slugs which are hierarchical (`mem/…`).
### Plaintext rationale
The d-tag is deliberately NOT blinded (contrast with [NIP-AE](NIP-AE.md) which HMAC-blinds d-tags to protect memory slug confidentiality). Personas are public definitions meant for discovery:
- Direct filter queries: `{kinds: [30175], authors: [pubkey], "#d": ["my-persona"]}`
- Human-readable addressing in UIs
- Cross-workspace sharing without a shared secret
## Event envelope
```jsonc
{
"kind": 30175,
"pubkey": "<pubkey_o>",
"created_at": <unix_seconds>,
"tags": [
["d", "<persona-slug>"]
],
"content": "<json_body>"
}
```
There MUST be exactly one `d` tag and it MUST contain a valid slug per the grammar above. The relay enforces this constraint on ingest. There is no `p` tag — persona events are owner-to-self definitions, not directed at a counterparty.
Implementations MAY include a [NIP-31](31.md) `["alt", "agent persona definition"]` tag to give unknown-kind viewers a non-leaking summary. Additional tags beyond `d` and `alt` are not defined by this NIP and have no effect on validity.
## Content body
The `content` field is a **plaintext** (unencrypted) JSON object:
```jsonc
{
"display_name": "<string>",
"system_prompt": "<string>",
"avatar_url": "<string | null>",
"runtime": "<string | null>",
"model": "<string | null>",
"provider": "<string | null>",
"name_pool": ["<string>", ...]
}
```
### Required fields
| Field | Type | Description |
|-------|------|-------------|
| `display_name` | string | Human-readable name for the persona. |
| `system_prompt` | string | The system prompt injected into agent sessions. |
### Optional fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `avatar_url` | string \| null | `null` | URL to an avatar image. |
| `runtime` | string \| null | `null` | ACP runtime identifier (e.g. `"goose"`, `"claude-code"`). |
| `model` | string \| null | `null` | Model identifier (e.g. `"claude-opus-4"`). |
| `provider` | string \| null | `null` | Model provider (e.g. `"anthropic"`). |
| `name_pool` | string[] | `[]` | Pool of display names for agent instances spawned from this persona. When non-empty, the spawning system picks a name from this pool for each new agent instance, enabling multiple concurrent agents from the same persona to have distinct identities. |
Unknown fields MUST be ignored by readers (forward compatibility).
### Prohibited: secrets in content
The content body is **public and unencrypted**. It MUST NOT contain secrets (API keys, tokens, credentials, or any sensitive environment variables). In particular, an `env_vars` field MUST NOT appear in the content body.
Secrets required by agents spawned from a persona MUST be conveyed through a separate encrypted channel — specifically, the [NIP-AE](NIP-AE.md) engram at `mem/persona` (which is NIP-44 encrypted to the agent↔owner conversation key) or through out-of-band injection at spawn time.
## Encryption rationale
Persona events carry no encryption. This is deliberate:
- Personas are *configuration*, not *state*. They describe what an agent should be, not what it has learned.
- Encryption would prevent relay-side indexing, search, and third-party client rendering — all desirable for definitions that workspace members should browse.
- Operators who need confidentiality should use relay-level access control ([NIP-42](42.md) authentication + [NIP-29](29.md) group membership) rather than event-level encryption.
## Replacement semantics
Standard NIP-33: for a given `(pubkey, kind:30175, d_tag)`, only the event with the greatest `created_at` is the **head**. Ties are broken by lowest event `id` per [NIP-01](01.md). Relays SHOULD return only the head; clients MUST select the head from any multi-event response.
## Writing
To write or update a persona with slug `s` and body `b`:
1. Validate `s` against the slug grammar. Reject if invalid.
2. Serialize `b` to JSON. Reject if the serialized body exceeds 65,535 bytes.
3. Compute the head of `s` per NIP-33 and let `T` be its `created_at` (or 0 if no head exists). Set `created_at := max(now, T + 1)`. Monotonicity ensures fresh writes always supersede prior heads regardless of clock skew.
4. Tags: `[["d", s]]`.
5. Sign with `seckey_o` and publish to configured relays.
## Reading
To read a single persona by slug `s`:
```
Filter: {kinds: [30175], authors: [pubkey_o], "#d": [s]}
```
Select the head per NIP-33 rules. Parse `content` as JSON. Validate required fields.
To list all personas for an owner:
```
Filter: {kinds: [30175], authors: [pubkey_o]}
```
Returns all heads. Clients scope by author pubkey — two different owners MAY publish personas with the same slug; these are independent events.
## Deletion
Owners MAY publish [NIP-09](09.md) deletion requests targeting persona events. A deletion request MUST be authored by the same key (`pubkey_o`). Such requests SHOULD include `["k", "30175"]` and use an `a`-tag identifier `30175:<pubkey_o>:<slug>`.
A subsequent write with a later timestamp resurrects the slug under NIP-33 replacement semantics.
## Relationships to other NIPs
### NIP-AE (Agent Engrams)
Agents spawned from a persona MAY store a private snapshot at the reserved engram slug `mem/persona`. This engram:
- Is NIP-44 encrypted (confidential to agent + owner)
- MAY contain secrets (env vars, API keys) that the public persona event must not carry
- Serves as the agent's private, mutable copy of its originating configuration
- References back to the persona event by slug convention, not by event ID
The `mem/persona` slug conforms to [NIP-AE](NIP-AE.md)'s slug grammar and requires no amendment to that spec.
### NIP-OA (Owner Attestation)
Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time.
## Relay behavior
- The relay MUST accept `kind:30175` events that pass standard NIP-33 validation (valid signature, exactly one `d` tag with a non-empty value).
- The relay stores persona events globally (`channel_id = NULL`); they are not channel-scoped.
- The relay is NOT required to validate that `content` parses as valid `PersonaEventContent` JSON. Relays are dumb stores per Nostr convention; content validation is a client responsibility.
- The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events).
## Security considerations
- **No encryption.** System prompts, model names, runtime identifiers, and all configuration are visible to anyone with relay read access. Operators MUST NOT store secrets in persona event content.
- **System prompt sensitivity.** System prompts may contain security-relevant behavioral instructions. Publishing them unencrypted enables adversarial prompt extraction. Operators who consider system prompts confidential SHOULD NOT publish them in persona events, or SHOULD use a relay with appropriate access controls.
- **Write authority.** Only the holder of `seckey_o` can publish or replace persona events. NIP-33 replacement is scoped by pubkey — no spoofing risk from other relay members.
- **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug.
- **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history.
- **No owner write authority over agents.** Persona events define *what* an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents.
## Reference test vectors
> **TEST KEYS — DO NOT USE IN PRODUCTION.** The keys below are pinned for reproducibility. Production code MUST source randomness from a CSPRNG.
### Inputs
```
seckey_o = 0000000000000000000000000000000000000000000000000000000000000001
schnorr_aux = 0000000000000000000000000000000000000000000000000000000000000000
```
### Derived
```
pubkey_o = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
```
### Event 1 — create persona with all fields
```jsonc
// Body (exact UTF-8, no trailing whitespace):
{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}
```
```
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000000
tags = [["d", "test-agent"]]
content = {"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}
id = <derived per NIP-01: sha256([0, pubkey, created_at, kind, tags, content])>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
```
### Event 2 — minimal persona (required fields only)
```jsonc
// Body:
{"display_name":"Minimal","system_prompt":"Hello."}
```
```
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000001
tags = [["d", "minimal"]]
content = {"display_name":"Minimal","system_prompt":"Hello."}
id = <derived per NIP-01>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
```
### Event 3 — replacement (same slug, higher `created_at`)
```jsonc
// Updated body (system_prompt changed):
{"display_name":"Test Agent","system_prompt":"You are an updated test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta","Gamma"]}
```
```
kind = 30175
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
created_at = 1700000002
tags = [["d", "test-agent"]]
content = {"display_name":"Test Agent","system_prompt":"You are an updated test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta","Gamma"]}
id = <derived per NIP-01>
sig = <BIP-340 Schnorr signature with aux=0x00…00>
```
After Event 3, the head for slug `test-agent` is Event 3 (greatest `created_at`). Event 1 is superseded.
### Head selection with tiebreak
If two events share `created_at = 1700000000` and slug `test-agent`, the head is the event with the lexicographically lowest `id` (hex comparison per NIP-01).
### Implementation notes
Unlike [NIP-AE](NIP-AE.md), persona events involve no encryption, no HMAC derivation, and no conversation key. The test vectors are standard NIP-33 events with JSON content — implementations need only:
1. Correct NIP-01 event-id serialization: `json.dumps([0, pubkey, created_at, kind, tags, content], separators=(",", ":"), ensure_ascii=False)` over UTF-8 bytes.
2. BIP-340 Schnorr signing with the pinned aux value.
3. JSON serialization of the content body with no trailing whitespace or BOM.
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# =============================================================================
# start-relay-for-tests.sh — Start the Buzz relay and its backing services
# =============================================================================
# Shared script for CI jobs that need a running relay. Starts docker compose
# services, waits for health, applies the schema, builds the relay, starts it,
# and polls readiness.
#
# Usage:
# ./scripts/start-relay-for-tests.sh [--profile <cargo-profile>]
#
# Options:
# --profile <profile> Cargo build profile (default: ci)
#
# Exports:
# RELAY_URL=ws://localhost:3000
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# ── Defaults ──────────────────────────────────────────────────────────────────
CARGO_PROFILE="${CARGO_PROFILE:-ci}"
# ── Parse args ────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--profile)
CARGO_PROFILE="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Colors ────────────────────────────────────────────────────────────────────
BLUE='\033[0;34m'
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${BLUE}[relay-test]${NC} $*"; }
ok() { echo -e "${GREEN}[relay-test]${NC} $*"; }
err() { echo -e "${RED}[relay-test]${NC} $*" >&2; }
# ── Start docker compose services ────────────────────────────────────────────
cd "${REPO_ROOT}"
log "Starting docker compose services..."
docker compose up -d postgres redis typesense minio minio-init
# ── Wait for services to be healthy ──────────────────────────────────────────
wait_healthy() {
local service="$1"
local container="$2"
log "Waiting for ${service}..."
for attempt in $(seq 1 60); do
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
if [ "${status}" = "healthy" ]; then
ok "${service} is healthy"
return 0
fi
sleep 2
done
err "${service} did not become healthy within 120s"
docker logs "${container}" || true
return 1
}
wait_healthy "Postgres" "buzz-postgres"
wait_healthy "Redis" "buzz-redis"
wait_healthy "Typesense" "buzz-typesense"
wait_healthy "MinIO" "buzz-minio"
# ── Apply database schema ────────────────────────────────────────────────────
log "Applying database schema..."
export PGHOST=localhost
export PGPORT=5432
export PGUSER=buzz
export PGPASSWORD=buzz_dev
export PGDATABASE=buzz
./bin/pgschema apply --file schema/schema.sql --auto-approve
ok "Schema applied"
# ── Build relay ──────────────────────────────────────────────────────────────
log "Building relay (profile: ${CARGO_PROFILE})..."
cargo build --profile "${CARGO_PROFILE}" -p buzz-relay
ok "Relay built"
# ── Start relay ──────────────────────────────────────────────────────────────
log "Starting relay..."
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
REDIS_URL=redis://localhost:6379 \
TYPESENSE_URL=http://localhost:8108 \
TYPESENSE_API_KEY=buzz_dev_key \
RELAY_URL=ws://localhost:3000 \
BUZZ_BIND_ADDR=0.0.0.0:3000 \
BUZZ_REQUIRE_AUTH_TOKEN=false \
BUZZ_RECONCILE_CHANNELS=true \
BUZZ_GIT_PROBE_WRITERS=8 \
"./target/${CARGO_PROFILE}/buzz-relay" > /tmp/buzz-relay.log 2>&1 &
echo $! > /tmp/buzz-relay.pid
# ── Poll readiness ───────────────────────────────────────────────────────────
log "Waiting for relay readiness..."
for attempt in $(seq 1 60); do
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
err "Relay process died"
cat /tmp/buzz-relay.log
exit 1
fi
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
if [ "${status_code}" = "200" ]; then
ok "Relay is ready at ws://localhost:3000"
export RELAY_URL=ws://localhost:3000
exit 0
fi
sleep 1
done
err "Relay did not become ready within 60s"
cat /tmp/buzz-relay.log
exit 1