mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
chore: remove verified dead code (#745)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -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<OwnedAgent>) -> 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
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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<nostr::Tag>,
|
||||
/// 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 })
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String>,
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name_pool: Vec<String>,
|
||||
#[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<Vec<LegacyPersonaRecord>, 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::<Vec<LegacyPersonaRecord>>(trimmed)?)
|
||||
} else if trimmed.starts_with('{') {
|
||||
Ok(vec![serde_json::from_str::<LegacyPersonaRecord>(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::<String>()
|
||||
.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<String> {
|
||||
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<MigrationReport, LegacyError> {
|
||||
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<String>,
|
||||
pub skipped_builtin: usize,
|
||||
pub skipped_inactive: usize,
|
||||
pub diagnostics: Vec<ValidationDiagnostic>,
|
||||
}
|
||||
|
||||
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<PersonaConfig, 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()));
|
||||
}
|
||||
|
||||
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."));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod legacy;
|
||||
pub mod manifest;
|
||||
pub mod merge;
|
||||
pub mod pack;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Kind> = 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]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HashMap<String, String>, 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<Vec<String>, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<String>, PubSubError> {
|
||||
let mut conn = pool.get().await?;
|
||||
let key = typing_key(channel_id);
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
|
||||
let members: Vec<String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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<String>], 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"];
|
||||
|
||||
@@ -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<Scope, &'static s
|
||||
| KIND_HUDDLE_PARTICIPANT_JOINED
|
||||
| KIND_HUDDLE_PARTICIPANT_LEFT
|
||||
| KIND_HUDDLE_ENDED
|
||||
| KIND_HUDDLE_TRACK_PUBLISHED
|
||||
| KIND_HUDDLE_RECORDING_AVAILABLE
|
||||
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
|
||||
// NIP-34: Git repository events
|
||||
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),
|
||||
|
||||
@@ -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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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<EventBuilder, SdkError> {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user