diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 1397e7f23..a4c8ba1f4 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -220,27 +220,6 @@ pub struct PromptContext { // ── AgentPool impl ──────────────────────────────────────────────────────────── impl AgentPool { - /// Create a new pool from a list of initialized agents. - /// - /// Agents are placed into indexed slots. The unbounded channel is created - /// here; tasks send results back through `result_tx`. - /// - /// Prefer [`AgentPool::from_slots`] for startup paths where some agents may - /// have failed — `new()` packs agents densely and will break the - /// `agent.index` invariant if any slot was skipped. - #[allow(dead_code)] - pub fn new(agents: Vec) -> Self { - let (result_tx, result_rx) = mpsc::unbounded_channel(); - let slots = agents.into_iter().map(Some).collect(); - Self { - agents: slots, - result_tx, - result_rx, - join_set: JoinSet::new(), - task_map: HashMap::new(), - } - } - /// Create a pool from pre-indexed slots (may contain None for failed startups). /// /// Slot positions are preserved so that `agent.index` always matches the diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index 4fb5865d6..6847f6bb9 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -265,7 +265,7 @@ impl EventQueue { }) .collect(); - // Remove the queue entry if now empty (keeps pending_channels() accurate). + // Remove the queue entry if now empty. if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { self.queues.remove(&channel_id); } @@ -490,20 +490,7 @@ impl EventQueue { .any(|id| !self.in_flight_channels.contains(id)) } - /// Whether any prompt is currently in flight. - #[allow(dead_code)] - pub fn is_in_flight(&self) -> bool { - !self.in_flight_channels.is_empty() - } - - /// Total number of pending events across all channels. - #[allow(dead_code)] - pub fn pending_count(&self) -> usize { - self.queues.values().map(|q| q.len()).sum() - } - /// Number of channels with pending events. - #[allow(dead_code)] pub fn pending_channels(&self) -> usize { self.queues.len() } @@ -517,9 +504,6 @@ impl EventQueue { /// /// Also clears any `retry_after` throttle for the channel. /// - /// Returns the number of events dropped. - /// Drop all queued (non-in-flight) events for a channel. - /// /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { @@ -540,7 +524,6 @@ impl EventQueue { } /// Whether a prompt is currently in-flight for the given channel. - #[allow(dead_code)] pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { self.in_flight_channels.contains(&channel_id) } @@ -1108,6 +1091,14 @@ mod tests { } } + fn pending_count(q: &EventQueue) -> usize { + q.queues.values().map(|q| q.len()).sum() + } + + fn any_in_flight(q: &EventQueue) -> bool { + !q.in_flight_channels.is_empty() + } + // ── Test 1: push + flush_next basic ────────────────────────────────────── #[test] @@ -1123,8 +1114,8 @@ mod tests { assert_eq!(batch.events[0].event.content, "hello"); // Queue should be empty now. - assert_eq!(q.pending_count(), 0); - assert_eq!(q.pending_channels(), 0); + assert_eq!(pending_count(&q), 0); + assert_eq!(q.queues.len(), 0); } // ── Test 2: same channel cannot be flushed twice ───────────────────────── @@ -1136,7 +1127,7 @@ mod tests { q.push(make_queued(ch, "first")); let _batch = q.flush_next().expect("first flush should succeed"); - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // Push another event while in-flight. q.push(make_queued(ch, "second")); @@ -1162,7 +1153,7 @@ mod tests { // Complete the in-flight prompt. q.mark_complete(ch); - assert!(!q.is_in_flight()); + assert!(!any_in_flight(&q)); // Now flush should succeed. let batch = q.flush_next().expect("should flush after mark_complete"); @@ -1182,7 +1173,7 @@ mod tests { q.push(make_queued(ch, "msg2")); q.push(make_queued(ch, "msg3")); - assert_eq!(q.pending_count(), 3); + assert_eq!(pending_count(&q), 3); let batch = q.flush_next().expect("should return batch"); assert_eq!(batch.channel_id, ch); @@ -1192,8 +1183,8 @@ mod tests { assert_eq!(batch.events[2].event.content, "msg3"); // All drained. - assert_eq!(q.pending_count(), 0); - assert_eq!(q.pending_channels(), 0); + assert_eq!(pending_count(&q), 0); + assert_eq!(q.queues.len(), 0); } // ── Test 5: FIFO fairness ───────────────────────────────────────────────── @@ -1229,11 +1220,11 @@ mod tests { // First flush picks A. let batch_a = q.flush_next().expect("first flush"); assert_eq!(batch_a.channel_id, ch_a); - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // B still pending. - assert_eq!(q.pending_count(), 1); - assert_eq!(q.pending_channels(), 1); + assert_eq!(pending_count(&q), 1); + assert_eq!(q.queues.len(), 1); q.mark_complete(ch_a); @@ -1242,7 +1233,7 @@ mod tests { assert_eq!(batch_b.channel_id, ch_b); assert_eq!(batch_b.events[0].event.content, "B-event"); - assert_eq!(q.pending_count(), 0); + assert_eq!(pending_count(&q), 0); } // ── Test 7: empty queue returns None ───────────────────────────────────── @@ -1253,37 +1244,6 @@ mod tests { assert!(q.flush_next().is_none()); } - // ── Test 8: pending_count ───────────────────────────────────────────────── - - #[test] - fn test_pending_count() { - let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); - - assert_eq!(q.pending_count(), 0); - assert_eq!(q.pending_channels(), 0); - - q.push(make_queued(ch_a, "a1")); - q.push(make_queued(ch_a, "a2")); - q.push(make_queued(ch_b, "b1")); - - assert_eq!(q.pending_count(), 3); - assert_eq!(q.pending_channels(), 2); - - // Flush A (2 events drained). - let _ = q.flush_next(); - assert_eq!(q.pending_count(), 1); - assert_eq!(q.pending_channels(), 1); - - q.mark_complete(ch_a); - - // Flush B (1 event drained). - let _ = q.flush_next(); - assert_eq!(q.pending_count(), 0); - assert_eq!(q.pending_channels(), 0); - } - // ── Test 9: format_prompt single event ─────────────────────────────────── #[test] @@ -1331,7 +1291,7 @@ mod tests { let batch = queue.flush_next().unwrap(); assert_eq!(batch.events.len(), 2); - assert!(queue.is_in_flight()); + assert!(any_in_flight(&queue)); // Simulate failure — requeue the batch. queue.requeue(batch); @@ -1491,11 +1451,11 @@ mod tests { q.push(make_queued(ch, "first")); let _batch = q.flush_next().expect("first flush"); - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // In drop mode, pushing to the in-flight channel should be discarded. q.push(make_queued(ch, "dropped")); - assert_eq!(q.pending_count(), 0, "event should be dropped"); + assert_eq!(pending_count(&q), 0, "event should be dropped"); q.mark_complete(ch); // Nothing to flush. @@ -1512,11 +1472,11 @@ mod tests { q.push(make_queued(ch_a, "A-first")); let _batch = q.flush_next().expect("flush A"); - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // Events for ch_b should still queue. q.push(make_queued(ch_b, "B-event")); - assert_eq!(q.pending_count(), 1); + assert_eq!(pending_count(&q), 1); q.mark_complete(ch_a); let batch_b = q.flush_next().expect("flush B"); @@ -1537,7 +1497,7 @@ mod tests { // Flush A — now A is in-flight. let batch_a = q.flush_next().expect("flush A"); assert_eq!(batch_a.channel_id, ch_a); - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // Flush B — B should also be flushable (different channel). let batch_b = q.flush_next().expect("flush B while A in-flight"); @@ -1548,11 +1508,11 @@ mod tests { // Complete A only. q.mark_complete(ch_a); - assert!(q.is_in_flight()); // B still in-flight. + assert!(any_in_flight(&q)); // B still in-flight. // Complete B. q.mark_complete(ch_b); - assert!(!q.is_in_flight()); + assert!(!any_in_flight(&q)); } // ── Test 15: same channel cannot be flushed twice ───────────────────────── @@ -1597,7 +1557,7 @@ mod tests { // Drop mode: pushing to either in-flight channel is dropped. q.push(make_queued(ch_a, "A-dropped")); q.push(make_queued(ch_b, "B-dropped")); - assert_eq!(q.pending_count(), 0); + assert_eq!(pending_count(&q), 0); q.mark_complete(ch_a); q.mark_complete(ch_b); @@ -1660,10 +1620,10 @@ mod tests { assert!(!q.in_flight_channels.contains(&ch_a)); // B still in-flight. - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); q.mark_complete(ch_b); - assert!(!q.is_in_flight()); + assert!(!any_in_flight(&q)); } // ── Test 19: requeue_preserve_timestamps preserves received_at ─────────── @@ -1722,19 +1682,19 @@ mod tests { for i in 0..MAX_PENDING_PER_CHANNEL { q.push(make_queued(ch, &format!("fill-{i}"))); } - assert_eq!(q.pending_count(), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); // Flush a batch (removes some events from the queue). let batch = q.flush_next().expect("should flush"); let batch_size = batch.events.len(); let remaining = MAX_PENDING_PER_CHANNEL - batch_size; - assert_eq!(q.pending_count(), remaining); + assert_eq!(pending_count(&q), remaining); // Push more events while the batch is "in-flight" — fill back to cap. for i in 0..batch_size { q.push(make_queued(ch, &format!("new-{i}"))); } - assert_eq!(q.pending_count(), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); // Requeue the original batch — without cap enforcement this would // push the queue to MAX_PENDING_PER_CHANNEL + batch_size. @@ -1742,9 +1702,9 @@ mod tests { // Cap must be enforced: queue should not exceed MAX_PENDING_PER_CHANNEL. assert!( - q.pending_count() <= MAX_PENDING_PER_CHANNEL, + pending_count(&q) <= MAX_PENDING_PER_CHANNEL, "queue exceeded cap: {} > {}", - q.pending_count(), + pending_count(&q), MAX_PENDING_PER_CHANNEL, ); } @@ -2358,11 +2318,11 @@ mod tests { q.push(make_queued(ch, "msg1")); q.push(make_queued(ch, "msg2")); - assert_eq!(q.pending_count(), 2); + assert_eq!(pending_count(&q), 2); let drained = q.drain_channel(ch); assert_eq!(drained.len(), 2); - assert_eq!(q.pending_count(), 0); + assert_eq!(pending_count(&q), 0); } #[test] @@ -2376,7 +2336,7 @@ mod tests { let drained = q.drain_channel(ch_a); assert_eq!(drained.len(), 1); - assert_eq!(q.pending_count(), 1); // ch_b still has 1 + assert_eq!(pending_count(&q), 1); // ch_b still has 1 } #[test] @@ -2393,7 +2353,7 @@ mod tests { assert!(!q.has_flushable_work()); let drained = q.drain_channel(ch); assert_eq!(drained.len(), 1); - assert_eq!(q.pending_count(), 0); + assert_eq!(pending_count(&q), 0); } #[test] @@ -2410,7 +2370,7 @@ mod tests { q.push(make_queued(ch, "msg1")); let _batch = q.flush_next().unwrap(); // now in-flight - assert!(q.is_in_flight()); + assert!(any_in_flight(&q)); // Push another event while in-flight. q.push(make_queued(ch, "msg2")); @@ -2418,7 +2378,7 @@ mod tests { // drain_channel should only remove the queued event, not the in-flight one. let drained = q.drain_channel(ch); assert_eq!(drained.len(), 1); - assert!(q.is_in_flight()); // in-flight unaffected + assert!(any_in_flight(&q)); // in-flight unaffected } // ── compact_expired_state ───────────────────────────────────────────── diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index aabc8a9fa..29c6f12ac 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -372,7 +372,6 @@ enum RelayCommand { filter: ChannelFilter, }, /// Unsubscribe from a channel (sends a NIP-01 CLOSE). - #[allow(dead_code)] Unsubscribe { channel_id: Uuid }, /// Reconnect to the relay (re-authenticate and resubscribe). Reconnect, @@ -416,11 +415,7 @@ pub struct HarnessRelay { relay_url: String, /// Keys used for NIP-42 signing and NIP-98 HTTP auth. keys: Keys, - /// Agent public key (hex) used as the `#p` filter on subscriptions. - #[allow(dead_code)] - agent_pubkey_hex: String, /// Optional NIP-OA auth tag for relay membership delegation. - #[allow(dead_code)] auth_tag: Option, /// Handle to the background task (for clean shutdown). /// Wrapped in `Option` so `shutdown()` can take ownership without conflicting @@ -500,7 +495,6 @@ impl HarnessRelay { .map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?, relay_url: relay_url.to_string(), keys: keys.clone(), - agent_pubkey_hex: agent_pubkey_hex.to_string(), auth_tag, bg_handle: Some(bg_handle), }) @@ -680,7 +674,6 @@ impl HarnessRelay { } /// Unsubscribe from a channel. - #[allow(dead_code)] pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> { self.cmd_tx .send(RelayCommand::Unsubscribe { channel_id }) diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 6df14ac54..979b9b515 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -132,14 +132,6 @@ pub const KIND_IA_UNARCHIVED: u32 = 8003; /// NIP-IA: Archived identities list snapshot (relay-signed, replaceable). pub const KIND_IA_ARCHIVED_LIST: u32 = 13535; -// System / admin (9100–9999) -/// V1 used kind:9001 — moved here due to NIP-29 conflict. -pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100; -/// V1 used kind:9010 — moved here for NIP-29 range safety. -pub const KIND_SYSTEM_SLASH_COMMAND: u32 = 9110; -/// Internal system flag event for admin tooling. -pub const KIND_SYSTEM_FLAG: u32 = 9900; - // NIP-29 group state (addressable range 39000–39003) /// NIP-29: Addressable group metadata state. pub const KIND_NIP29_GROUP_METADATA: u32 = 39000; @@ -199,9 +191,7 @@ pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). pub const KIND_SYSTEM_MESSAGE: u32 = 40099; -// Relay-only enrichment kinds (never client-submitted) -/// Thread summaries, reaction rollups (relay-signed sidecar). -pub const KIND_ENRICHMENT: u32 = 40900; +// Relay-only sidecar kinds (never client-submitted) /// Channel metadata with computed fields (relay-signed sidecar). pub const KIND_CHANNEL_SUMMARY: u32 = 40901; /// Bulk presence state (relay-signed sidecar). @@ -216,18 +206,6 @@ pub const KIND_DM_ADD_MEMBER: u32 = 41011; pub const KIND_DM_HIDE: u32 = 41012; /// A new direct-message conversation was created. pub const KIND_DM_CREATED: u32 = 41001; -/// A member was added to a DM conversation. -pub const KIND_DM_MEMBER_ADDED: u32 = 41002; -/// A member was removed from a DM conversation. -pub const KIND_DM_MEMBER_REMOVED: u32 = 41003; - -// Channel / topic management (42000–42999) -/// A new channel topic was created. -pub const KIND_TOPIC_CREATED: u32 = 42001; -/// An existing channel topic was updated. -pub const KIND_TOPIC_UPDATED: u32 = 42002; -/// A channel topic was archived. -pub const KIND_TOPIC_ARCHIVED: u32 = 42003; // Agent job protocol (43000–43999) // Not using NIP-90 kinds (5000–6999) — Sprout requires auth chains (depth ≤ 3, breadth ≤ 10). @@ -244,16 +222,6 @@ pub const KIND_JOB_CANCEL: u32 = 43005; /// An agent job failed with an error. pub const KIND_JOB_ERROR: u32 = 43006; -// Subscription system (44000–44999) -/// A new event subscription was created. -pub const KIND_SUBSCRIPTION_CREATED: u32 = 44001; -/// An event matched an active subscription. -pub const KIND_SUBSCRIPTION_MATCHED: u32 = 44002; -/// A subscription was paused. -pub const KIND_SUBSCRIPTION_PAUSED: u32 = 44003; -/// A paused subscription was resumed. -pub const KIND_SUBSCRIPTION_RESUMED: u32 = 44004; - /// Relay-signed notification: the target pubkey was added to a channel. /// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID. pub const KIND_MEMBER_ADDED_NOTIFICATION: u32 = 44100; @@ -300,24 +268,10 @@ pub const KIND_WORKFLOW_APPROVAL_GRANTED: u32 = 46011; pub const KIND_WORKFLOW_APPROVAL_DENIED: u32 = 46012; // User groups (47000–47999) -/// A new user group was created. -pub const KIND_USER_GROUP_CREATED: u32 = 47001; -/// An existing user group was updated. -pub const KIND_USER_GROUP_UPDATED: u32 = 47002; -/// A user group was deleted. -pub const KIND_USER_GROUP_DELETED: u32 = 47003; // System / admin custom range (48000–48999) /// An audit log entry was recorded. pub const KIND_AUDIT_ENTRY: u32 = 48001; -/// A compliance export was initiated. -pub const KIND_COMPLIANCE_EXPORT: u32 = 48002; -/// A knowledge crystal was created. -pub const KIND_KNOWLEDGE_CRYSTAL_CREATED: u32 = 48003; -/// A knowledge crystal was approved. -pub const KIND_KNOWLEDGE_CRYSTAL_APPROVED: u32 = 48004; -/// A knowledge crystal was updated. -pub const KIND_KNOWLEDGE_CRYSTAL_UPDATED: u32 = 48005; /// A huddle (audio/video session) was started. pub const KIND_HUDDLE_STARTED: u32 = 48100; /// A participant joined a huddle. @@ -326,10 +280,6 @@ pub const KIND_HUDDLE_PARTICIPANT_JOINED: u32 = 48101; pub const KIND_HUDDLE_PARTICIPANT_LEFT: u32 = 48102; /// A huddle ended. pub const KIND_HUDDLE_ENDED: u32 = 48103; -/// A media track was published in a huddle. -pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104; -/// A huddle recording became available. -pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105; /// Huddle channel guidelines/rules document. pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; @@ -398,9 +348,6 @@ pub const ALL_KINDS: &[u32] = &[ KIND_IA_ARCHIVED, KIND_IA_UNARCHIVED, KIND_IA_ARCHIVED_LIST, - KIND_SYSTEM_TIMER_FIRED, - KIND_SYSTEM_SLASH_COMMAND, - KIND_SYSTEM_FLAG, KIND_NIP29_GROUP_METADATA, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, @@ -421,28 +368,18 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_DIFF, KIND_CANVAS, KIND_SYSTEM_MESSAGE, - KIND_ENRICHMENT, KIND_CHANNEL_SUMMARY, KIND_PRESENCE_SNAPSHOT, KIND_DM_OPEN, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_CREATED, - KIND_DM_MEMBER_ADDED, - KIND_DM_MEMBER_REMOVED, - KIND_TOPIC_CREATED, - KIND_TOPIC_UPDATED, - KIND_TOPIC_ARCHIVED, KIND_JOB_REQUEST, KIND_JOB_ACCEPTED, KIND_JOB_PROGRESS, KIND_JOB_RESULT, KIND_JOB_CANCEL, KIND_JOB_ERROR, - KIND_SUBSCRIPTION_CREATED, - KIND_SUBSCRIPTION_MATCHED, - KIND_SUBSCRIPTION_PAUSED, - KIND_SUBSCRIPTION_RESUMED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_WORKFLOW_DEF, @@ -465,20 +402,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_WORKFLOW_APPROVAL_GRANTED, KIND_WORKFLOW_APPROVAL_DENIED, - KIND_USER_GROUP_CREATED, - KIND_USER_GROUP_UPDATED, - KIND_USER_GROUP_DELETED, KIND_AUDIT_ENTRY, - KIND_COMPLIANCE_EXPORT, - KIND_KNOWLEDGE_CRYSTAL_CREATED, - KIND_KNOWLEDGE_CRYSTAL_APPROVED, - KIND_KNOWLEDGE_CRYSTAL_UPDATED, KIND_HUDDLE_STARTED, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, - KIND_HUDDLE_TRACK_PUBLISHED, - KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_GUIDELINES, KIND_MEDIA_UPLOAD, KIND_GIT_REPO_ANNOUNCEMENT, @@ -549,13 +477,10 @@ pub const fn is_command_kind(kind: u32) -> bool { ) } -/// Returns `true` if `kind` is a relay-only enrichment kind (40900–40902). +/// Returns `true` if `kind` is a relay-only sidecar kind. /// Client submission of these kinds must be rejected. pub const fn is_relay_only_kind(kind: u32) -> bool { - matches!( - kind, - KIND_ENRICHMENT | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT - ) + matches!(kind, KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT) } /// Extract the kind from a nostr Event as u32. @@ -587,7 +512,7 @@ const _: () = assert!( // Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind. const _: () = assert!(KIND_AUTH <= u16::MAX as u32); const _: () = assert!(KIND_CANVAS <= u16::MAX as u32); -const _: () = assert!(KIND_HUDDLE_RECORDING_AVAILABLE <= u16::MAX as u32); +const _: () = assert!(KIND_HUDDLE_GUIDELINES <= u16::MAX as u32); const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX); #[cfg(test)] diff --git a/crates/sprout-persona/src/legacy.rs b/crates/sprout-persona/src/legacy.rs deleted file mode 100644 index 7019a1e94..000000000 --- a/crates/sprout-persona/src/legacy.rs +++ /dev/null @@ -1,759 +0,0 @@ -//! Legacy JSON persona format adapter. -//! -//! Reads the flat `.persona.json` format used by the Sprout desktop app -//! and converts to `.persona.md` (YAML frontmatter + markdown body). -//! -//! This is a read-only compatibility shim — the JSON format is deprecated. -//! The ACP harness only consumes `.persona.md`; the desktop converts at -//! deploy time via this adapter. - -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -use crate::persona::PersonaConfig; -use crate::validate::ValidationDiagnostic; - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[derive(Debug, thiserror::Error)] -pub enum LegacyError { - #[error("failed to read file: {0}")] - Io(#[from] std::io::Error), - - #[error("failed to parse JSON: {0}")] - Json(#[from] serde_json::Error), - - #[error("migration error: {0}")] - Migration(String), -} - -// ── Legacy persona record (desktop JSON format) ────────────────────────────── - -/// The flat JSON persona format stored by the Sprout desktop app. -/// -/// Source: `desktop/src-tauri/src/managed_agents/types.rs` → `PersonaRecord`. -/// This struct mirrors that layout for deserialization — we don't depend on -/// the desktop crate directly. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LegacyPersonaRecord { - pub id: String, - pub display_name: String, - #[serde(default)] - pub avatar_url: Option, - pub system_prompt: String, - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub name_pool: Vec, - #[serde(default)] - pub is_builtin: bool, - #[serde(default = "default_true")] - pub is_active: bool, - #[serde(default)] - pub created_at: String, - #[serde(default)] - pub updated_at: String, -} - -fn default_true() -> bool { - true -} - -// ── Loading ────────────────────────────────────────────────────────────────── - -/// Load legacy persona records from a JSON file. -/// -/// Accepts either a single `LegacyPersonaRecord` object or an array of them -/// (the desktop stores an array in `personas.json`). -pub fn load_legacy_json(path: &Path) -> Result, LegacyError> { - const MAX_LEGACY_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB - let metadata = std::fs::metadata(path)?; - if metadata.len() > MAX_LEGACY_FILE_SIZE { - return Err(LegacyError::Migration(format!( - "file too large: {} bytes (max {})", - metadata.len(), - MAX_LEGACY_FILE_SIZE - ))); - } - let content = std::fs::read_to_string(path)?; - let trimmed = content.trim(); - - if trimmed.starts_with('[') { - Ok(serde_json::from_str::>(trimmed)?) - } else if trimmed.starts_with('{') { - Ok(vec![serde_json::from_str::(trimmed)?]) - } else { - Err(LegacyError::Migration( - "file is not a JSON object or array".into(), - )) - } -} - -// ── Field mapping ──────────────────────────────────────────────────────────── - -/// Derive the V7 `name` field from the legacy `id`. -/// -/// Strips the `builtin:` prefix if present, lowercases, and replaces -/// spaces/special chars with hyphens. -pub fn derive_name(id: &str) -> String { - let stripped = id - .strip_prefix("builtin:") - .or_else(|| id.strip_prefix("custom:")) - .unwrap_or(id); - - stripped - .to_lowercase() - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '-' - } - }) - .collect::() - .trim_matches('-') - .to_string() -} - -/// Merge `provider` and `model` into the V7 `"provider:model-id"` format. -/// -/// Cases: -/// 1. Both present → `"provider:model-id"` -/// 2. Only model → `"model-id"` (no colon) -/// 3. Only provider → `None` (provider alone is not useful) -/// 4. Neither → `None` -pub fn merge_provider_model(provider: Option<&str>, model: Option<&str>) -> Option { - match (provider, model) { - (Some(p), Some(m)) if !p.is_empty() && !m.is_empty() => Some(format!("{p}:{m}")), - (_, Some(m)) if !m.is_empty() => Some(m.to_string()), - _ => None, - } -} - -/// Extract a one-line description from the system prompt. -/// -/// Takes the first non-empty line, truncated to at most 120 bytes of UTF-8. -/// Uses char boundaries to avoid slicing through multibyte codepoints. -/// Falls back to "Migrated persona" if the prompt is empty. -pub fn derive_description(system_prompt: &str) -> String { - system_prompt - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|line| { - if line.len() > 119 { - // Find the last char boundary ending at or before byte 119, - // so the result (content + '…') is at most 120 chars. - let truncate_at = line - .char_indices() - .take_while(|(i, _)| *i < 119) - .last() - .map(|(i, c)| i + c.len_utf8()) - .unwrap_or(119); - format!("{}…", &line[..truncate_at]) - } else { - line.to_string() - } - }) - .unwrap_or_else(|| "Migrated persona".to_string()) -} - -// ── Migration: JSON → .persona.md ──────────────────────────────────────────── - -/// Convert a single legacy record to `.persona.md` content (YAML frontmatter -/// + markdown body). -/// -/// Returns `(filename, content)` where filename is `{name}.persona.md`. -pub fn legacy_to_persona_md(record: &LegacyPersonaRecord) -> Result<(String, String), LegacyError> { - let name = derive_name(&record.id); - if name.is_empty() { - return Err(LegacyError::Migration(format!( - "could not derive a valid name from id {:?}", - record.id - ))); - } - if record.display_name.trim().is_empty() { - return Err(LegacyError::Migration("display_name is empty".into())); - } - - let description = derive_description(&record.system_prompt); - let model = merge_provider_model(record.provider.as_deref(), record.model.as_deref()); - - // Build YAML frontmatter using serde_yaml for correct escaping. - // BTreeMap keeps keys in alphabetical order for deterministic output. - let mut fm = serde_json::Map::new(); - fm.insert("name".into(), serde_json::Value::String(name.clone())); - fm.insert( - "display_name".into(), - serde_json::Value::String(record.display_name.clone()), - ); - fm.insert("description".into(), serde_json::Value::String(description)); - - if let Some(ref avatar) = record.avatar_url { - // Skip data: URIs — they're inline base64, not pack-relative paths. - if !avatar.starts_with("data:") && !avatar.is_empty() { - fm.insert("avatar".into(), serde_json::Value::String(avatar.clone())); - } - } - - if let Some(ref m) = model { - fm.insert("model".into(), serde_json::Value::String(m.clone())); - } - - let yaml_value = serde_json::Value::Object(fm); - let frontmatter = serde_yaml::to_string(&yaml_value) - .map_err(|e| LegacyError::Migration(format!("failed to serialize frontmatter: {e}")))?; - // serde_yaml emits a trailing newline; trim it since we add our own. - let frontmatter = frontmatter.trim_end(); - let body = &record.system_prompt; - - let content = format!("---\n{frontmatter}\n---\n\n{body}"); - let filename = format!("{name}.persona.md"); - - Ok((filename, content)) -} - -/// Migrate a legacy JSON file to a directory of `.persona.md` files. -/// -/// Creates the output directory if it doesn't exist. Skips inactive and -/// built-in personas by default (controlled by `include_builtin` and -/// `include_inactive`). -pub fn migrate_json_to_md( - input_path: &Path, - output_dir: &Path, - include_builtin: bool, - include_inactive: bool, -) -> Result { - let records = load_legacy_json(input_path)?; - std::fs::create_dir_all(output_dir)?; - - let mut report = MigrationReport::default(); - - for record in &records { - if !include_builtin && record.is_builtin { - report.skipped_builtin += 1; - continue; - } - if !include_inactive && !record.is_active { - report.skipped_inactive += 1; - continue; - } - - match legacy_to_persona_md(record) { - Ok((filename, content)) => { - let out_path = output_dir.join(&filename); - if out_path.exists() { - report - .diagnostics - .push(ValidationDiagnostic::Warning(format!( - "output file already exists, skipping: {}", - out_path.display() - ))); - continue; - } - std::fs::write(&out_path, &content)?; - report.migrated.push(filename); - } - Err(e) => { - report.diagnostics.push(ValidationDiagnostic::Error(format!( - "failed to migrate {:?}: {e}", - record.id - ))); - } - } - } - - Ok(report) -} - -/// Summary of a migration run. -#[derive(Debug, Default)] -pub struct MigrationReport { - pub migrated: Vec, - pub skipped_builtin: usize, - pub skipped_inactive: usize, - pub diagnostics: Vec, -} - -impl std::fmt::Display for MigrationReport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Migration complete:")?; - writeln!(f, " Migrated: {}", self.migrated.len())?; - for name in &self.migrated { - writeln!(f, " ✓ {name}")?; - } - if self.skipped_builtin > 0 { - writeln!(f, " Skipped (built-in): {}", self.skipped_builtin)?; - } - if self.skipped_inactive > 0 { - writeln!(f, " Skipped (inactive): {}", self.skipped_inactive)?; - } - for w in &self.diagnostics { - writeln!(f, " {w}")?; - } - Ok(()) - } -} - -// ── Adapter: LegacyPersonaRecord → PersonaConfig ───────────────────────────── - -/// Convert a legacy JSON persona record into a typed `PersonaConfig`. -/// -/// This is the bridge between the old flat JSON format and the new V7 struct. -/// Fields that don't exist in the legacy format (`skills`, `mcp_servers`, -/// `subscribe`, `respond_to`, `hooks`, etc.) get their default/empty values. -pub fn legacy_to_persona_config( - record: &LegacyPersonaRecord, -) -> Result { - let name = derive_name(&record.id); - if name.is_empty() { - return Err(LegacyError::Migration(format!( - "could not derive a valid name from id {:?}", - record.id - ))); - } - if record.display_name.trim().is_empty() { - return Err(LegacyError::Migration("display_name is empty".into())); - } - - Ok(PersonaConfig { - name, - display_name: record.display_name.clone(), - avatar: record - .avatar_url - .as_ref() - .filter(|a| !a.starts_with("data:") && !a.is_empty()) - .cloned(), - description: derive_description(&record.system_prompt), - version: None, - author: None, - skills: Vec::new(), - mcp_servers: Vec::new(), - subscribe: None, - triggers: None, - model: merge_provider_model(record.provider.as_deref(), record.model.as_deref()), - temperature: None, - max_context_tokens: None, - thread_replies: None, - broadcast_replies: None, - hooks: None, - prompt: record.system_prompt.clone(), - }) -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - // ── derive_name ────────────────────────────────────────────────────── - - #[test] - fn derive_name_strips_builtin_prefix() { - assert_eq!(derive_name("builtin:solo"), "solo"); - } - - #[test] - fn derive_name_strips_custom_prefix() { - assert_eq!(derive_name("custom:my-agent"), "my-agent"); - } - - #[test] - fn derive_name_lowercases() { - assert_eq!(derive_name("MyAgent"), "myagent"); - } - - #[test] - fn derive_name_replaces_spaces() { - assert_eq!(derive_name("My Cool Agent"), "my-cool-agent"); - } - - #[test] - fn derive_name_plain_id() { - assert_eq!(derive_name("lep"), "lep"); - } - - // ── merge_provider_model ───────────────────────────────────────────── - - #[test] - fn merge_both_present() { - assert_eq!( - merge_provider_model(Some("anthropic"), Some("claude-sonnet-4-20250514")), - Some("anthropic:claude-sonnet-4-20250514".into()) - ); - } - - #[test] - fn merge_model_only() { - assert_eq!( - merge_provider_model(None, Some("gpt-4o")), - Some("gpt-4o".into()) - ); - } - - #[test] - fn merge_provider_only() { - assert_eq!(merge_provider_model(Some("anthropic"), None), None); - } - - #[test] - fn merge_neither() { - assert_eq!(merge_provider_model(None, None), None); - } - - #[test] - fn merge_empty_strings() { - assert_eq!(merge_provider_model(Some(""), Some("")), None); - } - - #[test] - fn merge_empty_provider_with_model() { - assert_eq!( - merge_provider_model(Some(""), Some("gpt-4o")), - Some("gpt-4o".into()) - ); - } - - // ── derive_description ─────────────────────────────────────────────── - - #[test] - fn description_from_first_line() { - assert_eq!( - derive_description("You are a security bot.\nMore details here."), - "You are a security bot." - ); - } - - #[test] - fn description_skips_empty_lines() { - assert_eq!( - derive_description("\n\n \nActual content here."), - "Actual content here." - ); - } - - #[test] - fn description_empty_prompt() { - assert_eq!(derive_description(""), "Migrated persona"); - assert_eq!(derive_description(" \n \n "), "Migrated persona"); - } - - #[test] - fn description_truncates_long_line() { - let long = "x".repeat(200); - let desc = derive_description(&long); - assert!( - desc.chars().count() <= 120, - "should be at most 120 chars (119 + ellipsis)" - ); - assert!(desc.ends_with('…')); - } - - #[test] - fn description_truncates_multibyte_utf8() { - // 200 emoji chars — each is 4 bytes. Old code would panic on byte slice. - let long: String = "🔒".repeat(200); - let desc = derive_description(&long); - assert!(desc.chars().count() <= 120); - assert!(desc.ends_with('…')); - // Verify it's valid UTF-8 (would panic on construction if not). - assert!(!desc.is_empty()); - } - - #[test] - fn description_truncates_multibyte_safely() { - // 40 CJK characters = 120 bytes (3 bytes each), then more - let long = "你".repeat(50); // 150 bytes - let desc = derive_description(&long); - assert!(!desc.is_empty()); - // Should not panic, and should end with … - assert!(desc.ends_with('…')); - } - - // ── legacy_to_persona_md ───────────────────────────────────────────── - - #[test] - fn basic_conversion() { - let record = LegacyPersonaRecord { - id: "custom:lep".into(), - display_name: "Lep 🍀".into(), - avatar_url: Some("./avatars/lep.png".into()), - system_prompt: "You are Lep, a security reviewer.".into(), - provider: Some("anthropic".into()), - model: Some("claude-sonnet-4-20250514".into()), - name_pool: vec!["clover".into()], - is_builtin: false, - is_active: true, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-01-01T00:00:00Z".into(), - }; - - let (filename, content) = legacy_to_persona_md(&record).unwrap(); - assert_eq!(filename, "lep.persona.md"); - assert!(content.starts_with("---\n"), "should start with ---"); - // serde_yaml may or may not quote simple strings; check key: value pairs - assert!( - content.contains("name:") && content.contains("lep"), - "should contain name: lep" - ); - assert!( - content.contains("display_name:"), - "should contain display_name" - ); - assert!( - content.contains("model:") && content.contains("anthropic:claude-sonnet-4-20250514"), - "should contain model" - ); - assert!( - content.contains("avatar:") && content.contains("./avatars/lep.png"), - "should contain avatar" - ); - assert!( - content.contains("You are Lep, a security reviewer."), - "should contain system prompt" - ); - // name_pool should NOT appear - assert!( - !content.contains("name_pool"), - "name_pool should not appear" - ); - assert!( - !content.contains("clover"), - "name_pool values should not appear" - ); - } - - #[test] - fn conversion_skips_data_uri_avatar() { - let record = LegacyPersonaRecord { - id: "builtin:solo".into(), - display_name: "Solo".into(), - avatar_url: Some("data:image/png;base64,iVBOR...".into()), - system_prompt: "You are Solo.".into(), - provider: None, - model: None, - name_pool: vec![], - is_builtin: true, - is_active: true, - created_at: String::new(), - updated_at: String::new(), - }; - - let (_, content) = legacy_to_persona_md(&record).unwrap(); - assert!(!content.contains("avatar:")); - assert!(!content.contains("data:image")); - } - - #[test] - fn conversion_no_model() { - let record = LegacyPersonaRecord { - id: "custom:plain".into(), - display_name: "Plain".into(), - avatar_url: None, - system_prompt: "A simple agent.".into(), - provider: None, - model: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - created_at: String::new(), - updated_at: String::new(), - }; - - let (_, content) = legacy_to_persona_md(&record).unwrap(); - assert!(!content.contains("model:")); - } - - #[test] - fn conversion_preserves_prompt_whitespace() { - // Fix #4: migration must not trim leading/trailing whitespace from prompts. - let prompt = "\n You are Lep.\n\nBe helpful.\n "; - let record = LegacyPersonaRecord { - id: "custom:lep".into(), - display_name: "Lep".into(), - avatar_url: None, - system_prompt: prompt.into(), - provider: None, - model: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - created_at: String::new(), - updated_at: String::new(), - }; - - let (_, content) = legacy_to_persona_md(&record).unwrap(); - // The body section (after the closing ---\n\n) must contain the prompt verbatim. - let body_start = content.find("---\n\n").unwrap() + 5; - let body = &content[body_start..]; - assert_eq!(body, prompt, "prompt must be written as-is, not trimmed"); - } - - // ── load_legacy_json ───────────────────────────────────────────────── - - #[test] - fn load_single_object() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("single.json"); - std::fs::write( - &path, - r#"{"id":"custom:test","display_name":"Test","system_prompt":"Hello","created_at":"","updated_at":""}"#, - ) - .unwrap(); - - let records = load_legacy_json(&path).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].id, "custom:test"); - } - - #[test] - fn load_array() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("array.json"); - std::fs::write( - &path, - r#"[{"id":"a","display_name":"A","system_prompt":"","created_at":"","updated_at":""},{"id":"b","display_name":"B","system_prompt":"","created_at":"","updated_at":""}]"#, - ) - .unwrap(); - - let records = load_legacy_json(&path).unwrap(); - assert_eq!(records.len(), 2); - } - - // ── legacy_to_persona_config ───────────────────────────────────────── - - fn make_record(id: &str, prompt: &str) -> LegacyPersonaRecord { - LegacyPersonaRecord { - id: id.into(), - display_name: "Test Agent".into(), - avatar_url: Some("./avatars/test.png".into()), - system_prompt: prompt.into(), - provider: Some("anthropic".into()), - model: Some("claude-sonnet-4-20250514".into()), - name_pool: vec!["alpha".into()], - is_builtin: false, - is_active: true, - created_at: String::new(), - updated_at: String::new(), - } - } - - #[test] - fn adapter_basic() { - let record = make_record("custom:lep", "You are Lep."); - let config = legacy_to_persona_config(&record).unwrap(); - assert_eq!(config.name, "lep"); - assert_eq!(config.display_name, "Test Agent"); - assert_eq!(config.avatar.as_deref(), Some("./avatars/test.png")); - assert_eq!( - config.model.as_deref(), - Some("anthropic:claude-sonnet-4-20250514") - ); - assert_eq!(config.prompt, "You are Lep."); - assert!(config.skills.is_empty()); - assert!(config.mcp_servers.is_empty()); - assert!(config.subscribe.is_none()); - assert!(config.triggers.is_none()); - assert!(config.hooks.is_none()); - } - - #[test] - fn adapter_skips_data_uri_avatar() { - let mut record = make_record("builtin:solo", "Solo prompt."); - record.avatar_url = Some("data:image/png;base64,abc".into()); - let config = legacy_to_persona_config(&record).unwrap(); - assert!(config.avatar.is_none()); - } - - #[test] - fn adapter_no_model() { - let mut record = make_record("custom:plain", "Plain."); - record.provider = None; - record.model = None; - let config = legacy_to_persona_config(&record).unwrap(); - assert!(config.model.is_none()); - } - - #[test] - fn adapter_preserves_prompt_whitespace() { - // Fix #4: legacy_to_persona_config must not trim the system prompt. - let prompt = "\n You are Lep.\n\nBe helpful.\n "; - let record = make_record("custom:lep", prompt); - let config = legacy_to_persona_config(&record).unwrap(); - assert_eq!( - config.prompt, prompt, - "prompt must be stored as-is, not trimmed" - ); - } - - // ── empty display_name validation ──────────────────────────────────── - - #[test] - fn empty_display_name_is_rejected_by_md() { - let mut record = make_record("custom:lep", "You are Lep."); - record.display_name = "".into(); - let err = legacy_to_persona_md(&record).unwrap_err(); - assert!( - matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")), - "expected Migration error mentioning display_name, got: {err}" - ); - } - - #[test] - fn whitespace_only_display_name_is_rejected_by_md() { - let mut record = make_record("custom:lep", "You are Lep."); - record.display_name = " ".into(); - let err = legacy_to_persona_md(&record).unwrap_err(); - assert!(matches!(err, LegacyError::Migration(_))); - } - - #[test] - fn empty_display_name_is_rejected_by_config() { - let mut record = make_record("custom:lep", "You are Lep."); - record.display_name = "".into(); - let err = legacy_to_persona_config(&record).unwrap_err(); - assert!( - matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")), - "expected Migration error mentioning display_name, got: {err}" - ); - } - - #[test] - fn whitespace_only_display_name_is_rejected_by_config() { - let mut record = make_record("custom:lep", "You are Lep."); - record.display_name = " ".into(); - let err = legacy_to_persona_config(&record).unwrap_err(); - assert!(matches!(err, LegacyError::Migration(_))); - } - - // ── Round-trip: JSON → .persona.md → parse back ────────────────────── - - #[test] - fn round_trip_json_to_md_to_config() { - let record = make_record( - "custom:roundtrip", - "You are a round-trip test agent.\n\nBe helpful.", - ); - let (filename, md_content) = legacy_to_persona_md(&record).unwrap(); - assert_eq!(filename, "roundtrip.persona.md"); - - // Parse the generated .persona.md back using the real parser. - let parsed = crate::persona::parse_persona_md(&md_content).unwrap(); - - assert_eq!(parsed.name, "roundtrip"); - assert_eq!(parsed.display_name, "Test Agent"); - assert_eq!( - parsed.model.as_deref(), - Some("anthropic:claude-sonnet-4-20250514") - ); - assert_eq!(parsed.avatar.as_deref(), Some("./avatars/test.png")); - // The prompt should survive the round-trip. - assert!(parsed.prompt.contains("You are a round-trip test agent.")); - assert!(parsed.prompt.contains("Be helpful.")); - } -} diff --git a/crates/sprout-persona/src/lib.rs b/crates/sprout-persona/src/lib.rs index cacef6df4..f344242ca 100644 --- a/crates/sprout-persona/src/lib.rs +++ b/crates/sprout-persona/src/lib.rs @@ -1,4 +1,3 @@ -pub mod legacy; pub mod manifest; pub mod merge; pub mod pack; diff --git a/crates/sprout-persona/tests/integration.rs b/crates/sprout-persona/tests/integration.rs index 1fbeeba86..498f4905f 100644 --- a/crates/sprout-persona/tests/integration.rs +++ b/crates/sprout-persona/tests/integration.rs @@ -7,7 +7,6 @@ use std::fs; use std::path::Path; -use sprout_persona::legacy; use sprout_persona::pack; use sprout_persona::persona; use sprout_persona::resolve; @@ -267,115 +266,6 @@ You are a test agent. Be precise and thorough. assert_eq!(rt.all_messages, Some(false)); } -// ── Legacy JSON → PersonaConfig adapter ────────────────────────────────────── - -#[test] -fn legacy_adapter_full_pipeline() { - let record = legacy::LegacyPersonaRecord { - id: "custom:security-bot".into(), - display_name: "Security Bot 🔒".into(), - avatar_url: Some("./avatars/security.png".into()), - system_prompt: "You are a security-focused assistant.\n\nAnalyze code for vulnerabilities." - .into(), - provider: Some("anthropic".into()), - model: Some("claude-sonnet-4-20250514".into()), - name_pool: vec!["sentinel".into(), "guardian".into()], - is_builtin: false, - is_active: true, - created_at: "2026-01-01T00:00:00Z".into(), - updated_at: "2026-04-01T00:00:00Z".into(), - }; - - // 1. Convert to PersonaConfig. - let config = legacy::legacy_to_persona_config(&record).unwrap(); - assert_eq!(config.name, "security-bot"); - assert_eq!(config.display_name, "Security Bot 🔒"); - assert_eq!( - config.model.as_deref(), - Some("anthropic:claude-sonnet-4-20250514") - ); - assert!(config.prompt.contains("security-focused assistant")); - // name_pool should NOT survive migration. - // (No field for it in PersonaConfig — this is correct.) - - // 2. Generate .persona.md from the legacy record. - let (filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap(); - assert_eq!(filename, "security-bot.persona.md"); - - // 3. Parse the generated .persona.md back. - let parsed = persona::parse_persona_md(&md_content).unwrap(); - assert_eq!(parsed.name, "security-bot"); - assert_eq!(parsed.display_name, "Security Bot 🔒"); - assert_eq!( - parsed.model.as_deref(), - Some("anthropic:claude-sonnet-4-20250514") - ); - assert!(parsed.prompt.contains("security-focused assistant")); - assert!(parsed.prompt.contains("Analyze code for vulnerabilities")); -} - -// ── Legacy migration: batch JSON → pack directory ──────────────────────────── - -#[test] -fn legacy_batch_migration() { - let dir = tempfile::tempdir().unwrap(); - let input_path = dir.path().join("personas.json"); - let output_dir = dir.path().join("agents"); - - fs::write( - &input_path, - r#"[ - { - "id": "custom:alpha", - "display_name": "Alpha", - "system_prompt": "You are Alpha.", - "is_builtin": false, - "is_active": true, - "created_at": "", - "updated_at": "" - }, - { - "id": "builtin:solo", - "display_name": "Solo", - "system_prompt": "You are Solo.", - "is_builtin": true, - "is_active": true, - "created_at": "", - "updated_at": "" - }, - { - "id": "custom:inactive", - "display_name": "Inactive", - "system_prompt": "You are Inactive.", - "is_builtin": false, - "is_active": false, - "created_at": "", - "updated_at": "" - } -]"#, - ) - .unwrap(); - - // Migrate with defaults (skip builtin, skip inactive). - let report = legacy::migrate_json_to_md(&input_path, &output_dir, false, false).unwrap(); - - assert_eq!( - report.migrated.len(), - 1, - "only active non-builtin should migrate" - ); - assert_eq!(report.migrated[0], "alpha.persona.md"); - assert_eq!(report.skipped_builtin, 1); - assert_eq!(report.skipped_inactive, 1); - - // Verify the migrated file parses. - let content = fs::read_to_string(output_dir.join("alpha.persona.md")).unwrap(); - let parsed = persona::parse_persona_md(&content).unwrap(); - assert_eq!(parsed.name, "alpha"); - assert_eq!(parsed.display_name, "Alpha"); - assert!(parsed.prompt.contains("You are Alpha")); -} - // ── Validation catches real errors ─────────────────────────────────────────── #[test] @@ -700,53 +590,6 @@ fn resolve_persona_by_name_not_found() { ); } -/// Legacy JSON → PersonaConfig → .persona.md → resolve round-trip. -#[test] -fn resolve_legacy_migration_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - - // 1. Start with a legacy JSON record - let record = legacy::LegacyPersonaRecord { - id: "custom:security-bot".into(), - display_name: "Security Bot 🔒".into(), - avatar_url: Some("./avatars/security.png".into()), - system_prompt: "You are a security-focused assistant.".into(), - provider: Some("anthropic".into()), - model: Some("claude-sonnet-4-20250514".into()), - name_pool: vec![], - is_builtin: false, - is_active: true, - created_at: String::new(), - updated_at: String::new(), - }; - - // 2. Convert to .persona.md - let (_filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap(); - - // 3. Build a pack directory with the migrated persona - fs::create_dir_all(root.join(".plugin")).unwrap(); - fs::create_dir_all(root.join("agents")).unwrap(); - - fs::write( - root.join(".plugin/plugin.json"), - r#"{"id":"com.test.migrated","name":"Migrated","version":"1.0.0","personas":["agents/security-bot.persona.md"]}"#, - ) - .unwrap(); - fs::write(root.join("agents/security-bot.persona.md"), &md_content).unwrap(); - - // 4. Resolve the pack - let resolved = resolve::resolve_pack(root).unwrap(); - assert_eq!(resolved.personas.len(), 1); - - let persona = &resolved.personas[0]; - assert_eq!(persona.name, "security-bot"); - assert_eq!(persona.display_name, "Security Bot 🔒"); - assert_eq!(persona.provider.as_deref(), Some("anthropic")); - assert_eq!(persona.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert!(persona.system_prompt.contains("security-focused assistant")); -} - // ── Validation: zero-persona and duplicate-name in full pipeline ───────────── /// Validation catches zero-persona packs in the full pipeline. diff --git a/crates/sprout-proxy/src/translate.rs b/crates/sprout-proxy/src/translate.rs index 01b971482..6756782da 100644 --- a/crates/sprout-proxy/src/translate.rs +++ b/crates/sprout-proxy/src/translate.rs @@ -744,32 +744,6 @@ impl Translator { f } - - /// Translate a Sprout REQ filter to NIP-28 format (for outbound subscription - /// forwarding, e.g. when the proxy subscribes on behalf of a client). - /// - /// - kind:9 / kind:40002 → kind:42 - pub fn translate_filter_outbound(&self, filter: &Filter) -> Filter { - let mut f = filter.clone(); - - if let Some(ref kinds) = filter.kinds { - let new_kinds: Vec = kinds - .iter() - .map(|k| { - let k_u32 = k.as_u16() as u32; - let standard_k = self.kind_translator.to_standard(k_u32); - // SAFETY: standard kind values (42, 41) always fit in u16 - Kind::Custom( - u16::try_from(standard_k) - .expect("SAFETY: standard kind values always fit in u16"), - ) - }) - .collect(); - f = f.remove_kinds(kinds.iter().cloned()).kinds(new_kinds); - } - - f - } } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -1053,24 +1027,6 @@ mod tests { assert!(has_h_filter, "filter must have #h tag constraints injected"); } - #[test] - fn filter_outbound_translates_kind() { - let (translator, _) = make_translator(); - - let filter = Filter::new().kind(Kind::Custom(KIND_STREAM_MESSAGE as u16)); - let translated = translator.translate_filter_outbound(&filter); - - let kinds = translated.kinds.as_ref().expect("filter must have kinds"); - assert!( - kinds.contains(&Kind::Custom(42)), - "filter must contain kind:42 after outbound translation" - ); - assert!( - !kinds.contains(&Kind::Custom(KIND_STREAM_MESSAGE as u16)), - "filter must not contain KIND_STREAM_MESSAGE after outbound translation" - ); - } - // ── Test 6b: Filter — #e channel ref translates to #h UUID (FIX A) ───── #[test] diff --git a/crates/sprout-proxy/src/upstream.rs b/crates/sprout-proxy/src/upstream.rs index ae15d72b1..c55a44c63 100644 --- a/crates/sprout-proxy/src/upstream.rs +++ b/crates/sprout-proxy/src/upstream.rs @@ -155,30 +155,6 @@ impl UpstreamClient { self.inner.connected.try_read().map(|v| *v).unwrap_or(false) } - // ── Subscription tracking helpers ───────────────────────────────────────── - - /// Track a subscription by storing its REQ JSON for replay on reconnect. - /// Called by the server layer when forwarding REQs from downstream clients. - #[allow(dead_code)] // Used in tests; kept for future server-layer integration - pub(crate) fn track_subscription(&self, sub_id: &str, req_json: &str) { - self.inner - .active_subs - .insert(sub_id.to_string(), req_json.to_string()); - } - - /// Remove a subscription from the active set. - /// Called by the server layer when handling CLOSEs from downstream clients. - #[allow(dead_code)] // Used in tests; kept for future server-layer integration - pub(crate) fn untrack_subscription(&self, sub_id: &str) { - self.inner.active_subs.remove(sub_id); - } - - /// Returns the number of currently tracked active subscriptions. - #[allow(dead_code)] // Used in tests; kept for future server-layer integration - pub(crate) fn active_subscription_count(&self) -> usize { - self.inner.active_subs.len() - } - // ── Run loop ────────────────────────────────────────────────────────────── /// Run the upstream connection loop. Reconnects on disconnect with exponential @@ -592,36 +568,19 @@ mod tests { }); } - #[test] - fn track_and_untrack_subscriptions() { - let client = UpstreamClient::new("ws://localhost:3000", "sprout_test"); - - assert_eq!(client.active_subscription_count(), 0); - - client.track_subscription("sub-1", r#"["REQ","sub-1",{}]"#); - client.track_subscription("sub-2", r#"["REQ","sub-2",{}]"#); - assert_eq!(client.active_subscription_count(), 2); - - client.untrack_subscription("sub-1"); - assert_eq!(client.active_subscription_count(), 1); - - client.untrack_subscription("sub-2"); - assert_eq!(client.active_subscription_count(), 0); - } - #[test] fn send_req_tracks_subscription() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let client = UpstreamClient::new("ws://localhost:3000", "sprout_test"); - assert_eq!(client.active_subscription_count(), 0); + assert_eq!(client.inner.active_subs.len(), 0); let sub_id = SubscriptionId::new("tracked-sub"); let filters = vec![Filter::new().kind(Kind::TextNote)]; client.send_req(sub_id.clone(), filters).await.unwrap(); - assert_eq!(client.active_subscription_count(), 1); + assert_eq!(client.inner.active_subs.len(), 1); assert!( client.inner.active_subs.contains_key("tracked-sub"), "subscription should be tracked" @@ -629,7 +588,7 @@ mod tests { // CLOSE should remove it. client.send_close(sub_id).await.unwrap(); - assert_eq!(client.active_subscription_count(), 0); + assert_eq!(client.inner.active_subs.len(), 0); }); } } diff --git a/crates/sprout-pubsub/src/lib.rs b/crates/sprout-pubsub/src/lib.rs index 01bb42449..670b7a3db 100644 --- a/crates/sprout-pubsub/src/lib.rs +++ b/crates/sprout-pubsub/src/lib.rs @@ -32,8 +32,6 @@ pub mod rate_limiter; /// Redis SUBSCRIBE for channel event delivery. pub mod subscriber; /// Typing indicator tracking in Redis. -pub mod typing; - pub use error::PubSubError; use std::collections::HashMap; @@ -132,20 +130,6 @@ impl PubSubManager { ) -> Result, PubSubError> { presence::get_presence_bulk(&self.pool, pubkeys).await } - - /// Records that `pubkey` is currently typing in `channel_id`. Expires after 5 seconds. - pub async fn set_typing( - &self, - channel_id: Uuid, - pubkey: &PublicKey, - ) -> Result<(), PubSubError> { - typing::set_typing(&self.pool, channel_id, pubkey).await - } - - /// Returns hex pubkeys of users who have typed in `channel_id` within the last 5 seconds. - pub async fn get_typing(&self, channel_id: Uuid) -> Result, PubSubError> { - typing::get_typing(&self.pool, channel_id).await - } } #[cfg(test)] @@ -235,47 +219,4 @@ mod tests { let status = presence::get_presence(&pool, &pubkey).await.unwrap(); assert!(status.is_none()); } - - #[tokio::test] - #[ignore = "requires Redis"] - async fn test_typing_set_and_prune() { - let pool = make_test_pool(); - let channel_id = Uuid::new_v4(); - let pk1 = Keys::generate().public_key(); - let pk2 = Keys::generate().public_key(); - - typing::set_typing(&pool, channel_id, &pk1).await.unwrap(); - typing::set_typing(&pool, channel_id, &pk2).await.unwrap(); - - let active = typing::get_typing(&pool, channel_id).await.unwrap(); - assert!(active.contains(&pk1.to_hex())); - assert!(active.contains(&pk2.to_hex())); - - let stale_pk = Keys::generate().public_key(); - { - let mut conn = pool.get().await.unwrap(); - let key = typing::typing_key(channel_id); - let stale_score = chrono::Utc::now().timestamp() as f64 - 10.0; - redis::cmd("ZADD") - .arg(&key) - .arg(stale_score) - .arg(stale_pk.to_hex()) - .query_async::<()>(&mut conn) - .await - .unwrap(); - } - - typing::set_typing(&pool, channel_id, &pk1).await.unwrap(); - - let active = typing::get_typing(&pool, channel_id).await.unwrap(); - assert!(!active.contains(&stale_pk.to_hex())); - assert!(active.contains(&pk1.to_hex())); - - let mut conn = pool.get().await.unwrap(); - redis::cmd("DEL") - .arg(typing::typing_key(channel_id)) - .query_async::<()>(&mut conn) - .await - .unwrap(); - } } diff --git a/crates/sprout-pubsub/src/typing.rs b/crates/sprout-pubsub/src/typing.rs deleted file mode 100644 index 4cb0dd809..000000000 --- a/crates/sprout-pubsub/src/typing.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Typing indicators — Redis sorted set with 5-second active window. -//! -//! Each `set_typing` call ZADDs the member, prunes entries older than 5s, -//! and refreshes a key-level TTL of `TYPING_KEY_TTL_SECS` seconds on the -//! sorted set. This prevents orphaned keys from accumulating in Redis when a -//! channel goes quiet: individual members expire via `ZREMRANGEBYSCORE`, but -//! without a key-level TTL the empty sorted set would persist indefinitely. - -use deadpool_redis::Pool; -use nostr::PublicKey; -use uuid::Uuid; - -use crate::error::PubSubError; - -/// Active typing window in seconds. Members with a score older than this are pruned. -pub const TYPING_WINDOW_SECS: f64 = 5.0; - -/// Key-level TTL for the typing sorted set. If no `set_typing` call is made -/// for this duration, Redis automatically deletes the key, preventing orphaned -/// empty sets from accumulating when a channel goes permanently quiet. -/// -/// Must be longer than `TYPING_WINDOW_SECS` so that a key is never expired -/// while it still contains live members. -pub const TYPING_KEY_TTL_SECS: u64 = 60; - -/// Returns the Redis key for the typing sorted set of `channel_id`. -pub fn typing_key(channel_id: Uuid) -> String { - format!("sprout:typing:{}", channel_id) -} - -/// Records that `pubkey` is typing in `channel_id` and prunes stale entries. -pub async fn set_typing( - pool: &Pool, - channel_id: Uuid, - pubkey: &PublicKey, -) -> Result<(), PubSubError> { - let mut conn = pool.get().await?; - let key = typing_key(channel_id); - let now = chrono::Utc::now().timestamp() as f64; - - redis::cmd("ZADD") - .arg(&key) - .arg(now) - .arg(pubkey.to_hex()) - .query_async::<()>(&mut conn) - .await?; - - redis::cmd("ZREMRANGEBYSCORE") - .arg(&key) - .arg("-inf") - .arg(now - TYPING_WINDOW_SECS) - .query_async::<()>(&mut conn) - .await?; - - // Refresh key-level TTL so that orphaned empty sets are eventually - // reclaimed by Redis even if no further writes arrive for this channel. - redis::cmd("EXPIRE") - .arg(&key) - .arg(TYPING_KEY_TTL_SECS) - .query_async::<()>(&mut conn) - .await?; - - Ok(()) -} - -/// Returns hex pubkeys of users who typed in `channel_id` within the last [`TYPING_WINDOW_SECS`]. -pub async fn get_typing(pool: &Pool, channel_id: Uuid) -> Result, PubSubError> { - let mut conn = pool.get().await?; - let key = typing_key(channel_id); - let now = chrono::Utc::now().timestamp() as f64; - - let members: Vec = redis::cmd("ZRANGEBYSCORE") - .arg(&key) - .arg(now - TYPING_WINDOW_SECS) - .arg("+inf") - .query_async(&mut conn) - .await?; - - Ok(members) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_util::make_test_pool; - use nostr::Keys; - - #[test] - fn test_typing_key_format() { - let channel_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - assert_eq!( - typing_key(channel_id), - "sprout:typing:550e8400-e29b-41d4-a716-446655440000" - ); - } - - #[tokio::test] - #[ignore = "requires Redis"] - async fn test_typing_set_and_prune() { - let pool = make_test_pool(); - let channel_id = Uuid::new_v4(); - let pk1 = Keys::generate().public_key(); - let pk2 = Keys::generate().public_key(); - - set_typing(&pool, channel_id, &pk1).await.unwrap(); - set_typing(&pool, channel_id, &pk2).await.unwrap(); - - let typing = get_typing(&pool, channel_id).await.unwrap(); - assert_eq!(typing.len(), 2); - assert!(typing.contains(&pk1.to_hex())); - assert!(typing.contains(&pk2.to_hex())); - - // Insert a stale entry (score = now - 10s) - let stale_pk = Keys::generate().public_key(); - { - let mut conn = pool.get().await.unwrap(); - let key = typing_key(channel_id); - let stale_score = chrono::Utc::now().timestamp() as f64 - 10.0; - redis::cmd("ZADD") - .arg(&key) - .arg(stale_score) - .arg(stale_pk.to_hex()) - .query_async::<()>(&mut conn) - .await - .unwrap(); - } - - // Prune fires on next set_typing - set_typing(&pool, channel_id, &pk1).await.unwrap(); - - let typing = get_typing(&pool, channel_id).await.unwrap(); - assert!(!typing.contains(&stale_pk.to_hex())); - assert!(typing.contains(&pk1.to_hex())); - // pk1 + pk2 should remain (both within 5s window) - assert!(!typing.is_empty() && typing.len() <= 2); - - let mut conn = pool.get().await.unwrap(); - redis::cmd("DEL") - .arg(typing_key(channel_id)) - .query_async::<()>(&mut conn) - .await - .unwrap(); - } - - #[tokio::test] - #[ignore = "requires Redis"] - async fn test_typing_empty_channel() { - let pool = make_test_pool(); - let channel_id = Uuid::new_v4(); - let typing = get_typing(&pool, channel_id).await.unwrap(); - assert!(typing.is_empty()); - } -} diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index e5d6a0e08..6f525ddad 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -30,18 +30,17 @@ fn reject(reason: &'static str) { fn bounded_kind_label(kind: u32) -> String { match kind { 0..=9 | 1059 | 1063 => kind.to_string(), - 9000..=9022 | 9030..=9032 | 9100 | 9110 | 9900 => kind.to_string(), + 8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(), + 13534..=13535 => kind.to_string(), 20000..=29999 => kind.to_string(), 30023 | 30315 | 39000..=39003 => kind.to_string(), 40002..=40100 => kind.to_string(), - 41001..=41003 => kind.to_string(), - 42001..=42003 => kind.to_string(), + 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), - 44001..=44004 | 44100..=44101 => kind.to_string(), + 44100..=44101 => kind.to_string(), 45001..=45003 => kind.to_string(), - 46001..=46012 => kind.to_string(), - 47001..=47003 => kind.to_string(), - 48001..=48005 | 48100..=48105 => kind.to_string(), + 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), + 48001 | 48100..=48103 | 48106 => kind.to_string(), 49001 => kind.to_string(), _ => "other".to_string(), } diff --git a/crates/sprout-relay/src/handlers/imeta.rs b/crates/sprout-relay/src/handlers/imeta.rs index 880ea3a00..54cda929f 100644 --- a/crates/sprout-relay/src/handlers/imeta.rs +++ b/crates/sprout-relay/src/handlers/imeta.rs @@ -1,5 +1,7 @@ //! imeta tag validation helpers — shared between ingest pipeline and bridge. +use sprout_media::validation::mime_to_ext; + /// Validate imeta tags for correctness and safety. /// /// Shared between REST (send_message) and WebSocket (handle_event) paths. @@ -153,7 +155,7 @@ pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result } } if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) { - let expected_ext = mime_to_canonical_ext(&m_value); + let expected_ext = mime_to_ext(&m_value); if ext_in_url != expected_ext { return Err("imeta url extension does not match m".into()); } @@ -319,18 +321,6 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> { } } -/// Map MIME to canonical extension (must match sprout-media's mime_to_ext). -fn mime_to_canonical_ext(mime: &str) -> &str { - match mime { - "image/jpeg" => "jpg", - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - "video/mp4" => "mp4", - _ => "bin", - } -} - /// Validate that a URL references a valid local media blob path. fn is_local_media_url(url: &str, media_base_url: &str) -> bool { const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"]; diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 42d6fd4f7..ab08f06cf 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -20,18 +20,18 @@ use sprout_core::kind::{ KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, - KIND_HUDDLE_TRACK_PUBLISHED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_MUTE_LIST, 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_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, 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, }; use sprout_core::verification::verify_event; @@ -218,8 +218,6 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index 963315ce3..4c5f98763 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -622,116 +622,6 @@ pub fn build_contact_list( Ok(EventBuilder::new(Kind::Custom(3), "").tags(tags)) } -// ── Huddle shared helper ────────────────────────────────────────────────────── - -/// Shared builder for huddle lifecycle events (kinds 48100–48103). -/// -/// All huddle events share: an `["h", parent_channel_id]` tag, JSON content -/// with `ephemeral_channel_id`, optional extra content fields, and optional -/// p-tags for participant identity. -fn build_huddle_event_sdk( - kind: u16, - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - extra_fields: &[(&str, &str)], - participant_pubkey: Option<&str>, -) -> Result { - let mut tags = vec![tag(&["h", &parent_channel_id.to_string()])?]; - if let Some(pk) = participant_pubkey { - tags.push(tag(&["p", pk])?); - } - let mut map = serde_json::Map::new(); - map.insert( - "ephemeral_channel_id".into(), - serde_json::Value::String(ephemeral_channel_id.to_string()), - ); - for (k, v) in extra_fields { - map.insert((*k).into(), serde_json::Value::String(v.to_string())); - } - let content = serde_json::Value::Object(map).to_string(); - Ok(EventBuilder::new(Kind::Custom(kind), content).tags(tags)) -} - -// ── Builder 26: build_huddle_started ───────────────────────────────────────── - -/// Build a huddle-started event (kind 48100). -/// -/// Posted to the parent channel as an advisory UI hint that a huddle has begun. -/// - `parent_channel_id`: the channel the huddle belongs to (h-tag) -/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session -/// - `livekit_room`: LiveKit room name participants should join -pub fn build_huddle_started( - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - livekit_room: &str, -) -> Result { - build_huddle_event_sdk( - 48100, - parent_channel_id, - ephemeral_channel_id, - &[("livekit_room", livekit_room)], - None, - ) -} - -// ── Builder 27: build_huddle_participant_joined ─────────────────────────────── - -/// Build a huddle-participant-joined event (kind 48101). -/// -/// Posted to the parent channel when a participant enters the huddle. -/// - `parent_channel_id`: the channel the huddle belongs to (h-tag) -/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session -/// - `participant_pubkey`: hex pubkey of the joining participant (p-tag) -pub fn build_huddle_participant_joined( - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - participant_pubkey: &str, -) -> Result { - build_huddle_event_sdk( - 48101, - parent_channel_id, - ephemeral_channel_id, - &[], - Some(participant_pubkey), - ) -} - -// ── Builder 28: build_huddle_participant_left ───────────────────────────────── - -/// Build a huddle-participant-left event (kind 48102). -/// -/// Posted to the parent channel when a participant exits the huddle. -/// - `parent_channel_id`: the channel the huddle belongs to (h-tag) -/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session -/// - `participant_pubkey`: hex pubkey of the departing participant (p-tag) -pub fn build_huddle_participant_left( - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - participant_pubkey: &str, -) -> Result { - build_huddle_event_sdk( - 48102, - parent_channel_id, - ephemeral_channel_id, - &[], - Some(participant_pubkey), - ) -} - -// ── Builder 29: build_huddle_ended ─────────────────────────────────────────── - -/// Build a huddle-ended event (kind 48103). -/// -/// Posted to the parent channel when the huddle session concludes. -/// - `parent_channel_id`: the channel the huddle belongs to (h-tag) -/// - `ephemeral_channel_id`: the short-lived channel UUID for the huddle session -pub fn build_huddle_ended( - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, -) -> Result { - build_huddle_event_sdk(48103, parent_channel_id, ephemeral_channel_id, &[], None) -} - // ── Helper: extract_channel_id ─────────────────────────────────────────────── /// Extract the channel UUID from an event's `h` tag. @@ -1899,101 +1789,6 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } - // ── build_huddle_started ────────────────────────────────────────────────── - - #[test] - fn huddle_started_happy_path() { - let parent = uuid(); - let ephemeral = uuid(); - let ev = sign(build_huddle_started(parent, ephemeral, "my-room").unwrap()); - assert_eq!(ev.kind.as_u16(), 48100); - assert!(has_tag(&ev, "h", &parent.to_string())); - let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); - assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string()); - assert_eq!(v["livekit_room"], "my-room"); - } - - #[test] - fn huddle_started_h_tag_is_parent_not_ephemeral() { - let parent = uuid(); - let ephemeral = uuid(); - let ev = sign(build_huddle_started(parent, ephemeral, "room").unwrap()); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(!has_tag(&ev, "h", &ephemeral.to_string())); - } - - // ── build_huddle_participant_joined ─────────────────────────────────────── - - #[test] - fn huddle_participant_joined_happy_path() { - let parent = uuid(); - let ephemeral = uuid(); - let pubkey = "a".repeat(64); - let ev = sign(build_huddle_participant_joined(parent, ephemeral, &pubkey).unwrap()); - assert_eq!(ev.kind.as_u16(), 48101); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(has_tag(&ev, "p", &pubkey)); - let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); - assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string()); - } - - #[test] - fn huddle_participant_joined_h_tag_is_parent_not_ephemeral() { - let parent = uuid(); - let ephemeral = uuid(); - let pubkey = "b".repeat(64); - let ev = sign(build_huddle_participant_joined(parent, ephemeral, &pubkey).unwrap()); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(!has_tag(&ev, "h", &ephemeral.to_string())); - } - - // ── build_huddle_participant_left ───────────────────────────────────────── - - #[test] - fn huddle_participant_left_happy_path() { - let parent = uuid(); - let ephemeral = uuid(); - let pubkey = "c".repeat(64); - let ev = sign(build_huddle_participant_left(parent, ephemeral, &pubkey).unwrap()); - assert_eq!(ev.kind.as_u16(), 48102); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(has_tag(&ev, "p", &pubkey)); - let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); - assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string()); - } - - #[test] - fn huddle_participant_left_h_tag_is_parent_not_ephemeral() { - let parent = uuid(); - let ephemeral = uuid(); - let pubkey = "d".repeat(64); - let ev = sign(build_huddle_participant_left(parent, ephemeral, &pubkey).unwrap()); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(!has_tag(&ev, "h", &ephemeral.to_string())); - } - - // ── build_huddle_ended ──────────────────────────────────────────────────── - - #[test] - fn huddle_ended_happy_path() { - let parent = uuid(); - let ephemeral = uuid(); - let ev = sign(build_huddle_ended(parent, ephemeral).unwrap()); - assert_eq!(ev.kind.as_u16(), 48103); - assert!(has_tag(&ev, "h", &parent.to_string())); - let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap(); - assert_eq!(v["ephemeral_channel_id"], ephemeral.to_string()); - } - - #[test] - fn huddle_ended_h_tag_is_parent_not_ephemeral() { - let parent = uuid(); - let ephemeral = uuid(); - let ev = sign(build_huddle_ended(parent, ephemeral).unwrap()); - assert!(has_tag(&ev, "h", &parent.to_string())); - assert!(!has_tag(&ev, "h", &ephemeral.to_string())); - } - // ── build_repo_announcement ─────────────────────────────────────────────── #[test]