mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Generated
+276
-277
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Executable
+138
@@ -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
|
||||
Reference in New Issue
Block a user