1use std::collections::BTreeMap;
19use std::sync::Arc;
20
21use meerkat_mob::MobError;
22use meerkat_mob::event::{AttributedEvent, MobEvent, MobEventKind};
23use meerkat_mob::runtime::MobEventsView;
24use serde_json::Value;
25use tokio::sync::broadcast;
26
27use crate::runtime::{MetadataScope, RuntimeMetadataTable};
28use crate::types::MobStructuralEventEnvelope;
29use crate::unified_runtime::EventQuery;
30
31pub(crate) const QUERY_BATCH_SIZE: usize = 128;
34
35pub(crate) const DEFAULT_QUERY_LIMIT: usize = 256;
37
38const MOB_EVENTS_CHANNEL_CAP: usize = 512;
40
41#[derive(Clone)]
47pub struct MobEventsStore {
48 event_tx: broadcast::Sender<MobStructuralEventEnvelope>,
49 metadata_table: Option<Arc<RuntimeMetadataTable>>,
50}
51
52impl Default for MobEventsStore {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl MobEventsStore {
59 pub fn new() -> Self {
65 let (event_tx, _) = broadcast::channel(MOB_EVENTS_CHANNEL_CAP);
66 Self {
67 event_tx,
68 metadata_table: None,
69 }
70 }
71
72 #[must_use]
78 pub fn with_metadata_table(mut self, table: Arc<RuntimeMetadataTable>) -> Self {
79 self.metadata_table = Some(table);
80 self
81 }
82
83 pub fn subscribe(&self) -> broadcast::Receiver<MobStructuralEventEnvelope> {
89 self.event_tx.subscribe()
90 }
91
92 pub async fn project_attributed_event(
102 &self,
103 _event: &AttributedEvent,
104 ) -> Option<MobStructuralEventEnvelope> {
105 None
106 }
107
108 pub async fn project_mob_event(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
112 let envelope = self.build_envelope(event).await;
113 let _ = self.event_tx.send(envelope.clone());
114 envelope
115 }
116
117 pub async fn project_event_for_query(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
121 self.build_envelope(event).await
122 }
123
124 async fn build_envelope(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
125 let cursor = event.cursor;
126 let mob_id = event.mob_id.as_str().to_string();
127 let timestamp_ms = event.timestamp.timestamp_millis().max(0) as u64;
128 let kind = event_kind_label(&event.kind).to_string();
129 let (run_id, step_id, agent_identity) = extract_structural_fields(&event.kind);
130 let data = serde_json::to_value(&event.kind).unwrap_or(Value::Null);
131 let (mob_labels, run_labels) = self.lookup_labels(&mob_id, run_id.as_deref()).await;
132 MobStructuralEventEnvelope {
133 event_id: format!("mob-evt-{cursor}"),
134 cursor,
135 mob_id,
136 timestamp_ms,
137 kind,
138 run_id,
139 step_id,
140 agent_identity,
141 mob_labels,
142 run_labels,
143 data,
144 }
145 }
146
147 async fn lookup_labels(
148 &self,
149 mob_id: &str,
150 run_id: Option<&str>,
151 ) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
152 let Some(table) = &self.metadata_table else {
153 return (BTreeMap::new(), BTreeMap::new());
154 };
155 let mob_labels = table
156 .get_labels(&MetadataScope::Mob(mob_id.to_string()))
157 .await;
158 let run_labels = match run_id {
159 Some(run_id) => {
160 table
161 .get_labels(&MetadataScope::Run(mob_id.to_string(), run_id.to_string()))
162 .await
163 }
164 None => BTreeMap::new(),
165 };
166 (mob_labels, run_labels)
167 }
168}
169
170pub const MOB_EVENTS_STREAM_PATH: &str = "/mobkit/mob_events/stream";
172
173pub(crate) fn build_subscribe_url(
183 query: &EventQuery,
184 next_after_seq: Option<u64>,
185 fallback_cursor: u64,
186) -> String {
187 let after_seq = next_after_seq
188 .or(query.after_seq)
189 .unwrap_or(fallback_cursor);
190 let mut serializer = form_urlencoded::Serializer::new(String::new());
191 serializer.append_pair("after_seq", &after_seq.to_string());
192 if let Some(value) = query.mob_id.as_deref() {
193 serializer.append_pair("mob_id", value);
194 }
195 if let Some(value) = query.run_id.as_deref() {
196 serializer.append_pair("run_id", value);
197 }
198 if let Some(value) = query.step_id.as_deref() {
199 serializer.append_pair("step_id", value);
200 }
201 if let Some(value) = query.identity.as_deref() {
202 serializer.append_pair("identity", value);
203 }
204 if let Some(value) = query.member_id.as_deref() {
205 serializer.append_pair("member_id", value);
206 }
207 if let Some(value) = query.since_ms {
208 serializer.append_pair("since_ms", &value.to_string());
209 }
210 if let Some(value) = query.until_ms {
211 serializer.append_pair("until_ms", &value.to_string());
212 }
213 if !query.event_types.is_empty() {
214 serializer.append_pair("event_types", &query.event_types.join(","));
215 }
216 format!("{MOB_EVENTS_STREAM_PATH}?{}", serializer.finish())
217}
218
219#[derive(Debug)]
223pub enum MobEventsQueryError {
224 Stale {
226 after_cursor: u64,
227 latest_cursor: u64,
228 },
229 Backend(MobError),
231}
232
233impl std::fmt::Display for MobEventsQueryError {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 match self {
236 Self::Stale {
237 after_cursor,
238 latest_cursor,
239 } => write!(
240 f,
241 "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
242 ),
243 Self::Backend(err) => write!(f, "{err}"),
244 }
245 }
246}
247
248impl std::error::Error for MobEventsQueryError {}
249
250impl From<MobError> for MobEventsQueryError {
251 fn from(err: MobError) -> Self {
252 if let MobError::StaleEventCursor {
253 after_cursor,
254 latest_cursor,
255 } = err
256 {
257 Self::Stale {
258 after_cursor,
259 latest_cursor,
260 }
261 } else {
262 Self::Backend(err)
263 }
264 }
265}
266
267pub(crate) fn envelope_matches(envelope: &MobStructuralEventEnvelope, query: &EventQuery) -> bool {
271 if let Some(since) = query.since_ms
272 && envelope.timestamp_ms < since
273 {
274 return false;
275 }
276 if let Some(until) = query.until_ms
277 && envelope.timestamp_ms >= until
278 {
279 return false;
280 }
281 if let Some(mob_id) = query.mob_id.as_deref()
282 && envelope.mob_id != mob_id
283 {
284 return false;
285 }
286 if let Some(run_id) = query.run_id.as_deref()
287 && envelope.run_id.as_deref() != Some(run_id)
288 {
289 return false;
290 }
291 if let Some(step_id) = query.step_id.as_deref()
292 && envelope.step_id.as_deref() != Some(step_id)
293 {
294 return false;
295 }
296 let identity_filter = query.identity.as_deref().or(query.member_id.as_deref());
297 if let Some(identity) = identity_filter
298 && envelope.agent_identity.as_deref() != Some(identity)
299 {
300 return false;
301 }
302 if !query.event_types.is_empty() && !query.event_types.iter().any(|ty| ty == &envelope.kind) {
303 return false;
304 }
305 true
306}
307
308pub(crate) async fn query_ledger_with_filter(
322 events: &MobEventsView,
323 store: &MobEventsStore,
324 query: &EventQuery,
325) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
326 let limit = query.limit.unwrap_or(DEFAULT_QUERY_LIMIT);
327 if limit == 0 {
328 return Ok(Vec::new());
329 }
330 if let Some(after_seq) = query.after_seq {
331 return scan_forward(events, store, query, after_seq, limit).await;
332 }
333 scan_backward(events, store, query, limit).await
334}
335
336async fn scan_forward(
337 events: &MobEventsView,
338 store: &MobEventsStore,
339 query: &EventQuery,
340 after_seq: u64,
341 limit: usize,
342) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
343 let mut results: Vec<MobStructuralEventEnvelope> =
344 Vec::with_capacity(limit.min(QUERY_BATCH_SIZE));
345 let mut cursor = after_seq;
346 loop {
347 let batch = events.poll_strict(cursor, QUERY_BATCH_SIZE).await?;
348 if batch.is_empty() {
349 break;
350 }
351 let cursor_before_batch = cursor;
352 for event in batch {
353 cursor = cursor.max(event.cursor);
354 let envelope = store.project_event_for_query(&event).await;
355 if envelope_matches(&envelope, query) {
356 results.push(envelope);
357 if results.len() >= limit {
358 return Ok(results);
359 }
360 }
361 }
362 if cursor <= cursor_before_batch {
368 break;
369 }
370 }
371 Ok(results)
372}
373
374async fn scan_backward(
375 events: &MobEventsView,
376 store: &MobEventsStore,
377 query: &EventQuery,
378 limit: usize,
379) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
380 let latest = events.latest_cursor().await?;
381 if latest == 0 {
382 return Ok(Vec::new());
383 }
384 let batch_size = QUERY_BATCH_SIZE as u64;
385 let mut window_end = latest;
386 let mut accumulator: Vec<MobStructuralEventEnvelope> = Vec::new();
387 loop {
388 let from = window_end.saturating_sub(batch_size);
389 let take = (window_end - from) as usize;
390 if take == 0 {
391 break;
392 }
393 let batch = events.poll_strict(from, take).await?;
394 if batch.is_empty() {
395 break;
396 }
397 let mut window_matches: Vec<MobStructuralEventEnvelope> = Vec::with_capacity(batch.len());
398 for event in batch {
399 let envelope = store.project_event_for_query(&event).await;
400 if envelope_matches(&envelope, query) {
401 window_matches.push(envelope);
402 }
403 }
404 let mut combined = Vec::with_capacity(window_matches.len() + accumulator.len());
406 combined.append(&mut window_matches);
407 combined.append(&mut accumulator);
408 accumulator = combined;
409 if accumulator.len() >= limit || from == 0 {
410 break;
411 }
412 window_end = from;
413 }
414 if accumulator.len() > limit {
415 let drop = accumulator.len() - limit;
416 accumulator.drain(0..drop);
417 }
418 Ok(accumulator)
419}
420
421fn event_kind_label(kind: &MobEventKind) -> &'static str {
424 match kind {
425 MobEventKind::MobCreated { .. } => "mob_created",
426 MobEventKind::MobOwnerBridgeSessionBound { .. } => "mob_owner_bridge_session_bound",
427 MobEventKind::MobCompleted => "mob_completed",
428 MobEventKind::MobDestroying => "mob_destroying",
429 MobEventKind::MobDestroyStorageFinalizing => "mob_destroy_storage_finalizing",
430 MobEventKind::MobReset => "mob_reset",
431 MobEventKind::MemberSpawned(_) => "member_spawned",
432 MobEventKind::MemberSessionBindingRecovered(_) => "member_session_binding_recovered",
433 MobEventKind::MemberRetired { .. } => "member_retired",
434 MobEventKind::MemberReset { .. } => "member_reset",
435 MobEventKind::MemberKickoffUpdated { .. } => "member_kickoff_updated",
436 MobEventKind::MembersWired { .. } => "members_wired",
437 MobEventKind::MembersWiredBatch { .. } => "members_wired_batch",
438 MobEventKind::MembersUnwired { .. } => "members_unwired",
439 MobEventKind::ExternalPeerWired { .. } => "external_peer_wired",
440 MobEventKind::ExternalPeerUnwired { .. } => "external_peer_unwired",
441 MobEventKind::FlowStarted { .. } => "flow_started",
442 MobEventKind::FlowCompleted { .. } => "flow_completed",
443 MobEventKind::FlowFailed { .. } => "flow_failed",
444 MobEventKind::FlowCanceled { .. } => "flow_canceled",
445 MobEventKind::StepDispatched { .. } => "step_dispatched",
446 MobEventKind::StepTargetCompleted { .. } => "step_target_completed",
447 MobEventKind::StepTargetFailed { .. } => "step_target_failed",
448 MobEventKind::StepCompleted { .. } => "step_completed",
449 MobEventKind::StepFailed { .. } => "step_failed",
450 MobEventKind::StepSkipped { .. } => "step_skipped",
451 MobEventKind::TopologyViolation { .. } => "topology_violation",
452 MobEventKind::SupervisorEscalation { .. } => "supervisor_escalation",
453 MobEventKind::OperatorActionRecorded { .. } => "operator_action_recorded",
454 }
455}
456
457fn decode_member_id(member_id: &str) -> String {
465 crate::member_comms_id::runtime_alias_str(member_id).into_owned()
466}
467
468pub(crate) fn extract_structural_fields(
475 kind: &MobEventKind,
476) -> (Option<String>, Option<String>, Option<String>) {
477 match kind {
478 MobEventKind::FlowStarted { run_id, .. }
479 | MobEventKind::FlowCompleted { run_id, .. }
480 | MobEventKind::FlowFailed { run_id, .. }
481 | MobEventKind::FlowCanceled { run_id, .. } => (Some(run_id.to_string()), None, None),
482 MobEventKind::StepDispatched {
483 run_id,
484 step_id,
485 target,
486 }
487 | MobEventKind::StepTargetCompleted {
488 run_id,
489 step_id,
490 target,
491 } => (
492 Some(run_id.to_string()),
493 Some(step_id.as_str().to_string()),
494 Some(decode_member_id(target.identity.as_str())),
495 ),
496 MobEventKind::StepTargetFailed {
497 run_id,
498 step_id,
499 target,
500 ..
501 } => (
502 Some(run_id.to_string()),
503 Some(step_id.as_str().to_string()),
504 Some(decode_member_id(target.identity.as_str())),
505 ),
506 MobEventKind::StepCompleted { run_id, step_id }
507 | MobEventKind::StepFailed {
508 run_id, step_id, ..
509 }
510 | MobEventKind::StepSkipped {
511 run_id, step_id, ..
512 } => (
513 Some(run_id.to_string()),
514 Some(step_id.as_str().to_string()),
515 None,
516 ),
517 MobEventKind::SupervisorEscalation {
518 run_id,
519 step_id,
520 escalated_to,
521 } => (
522 Some(run_id.to_string()),
523 Some(step_id.as_str().to_string()),
524 Some(decode_member_id(escalated_to.as_str())),
525 ),
526 MobEventKind::MemberSpawned(event) => (
527 None,
528 None,
529 Some(decode_member_id(event.agent_identity.as_str())),
530 ),
531 MobEventKind::MemberSessionBindingRecovered(event) => (
534 None,
535 None,
536 Some(decode_member_id(event.agent_identity.as_str())),
537 ),
538 MobEventKind::MemberRetired { agent_identity, .. }
539 | MobEventKind::MemberReset { agent_identity, .. } => {
540 (None, None, Some(decode_member_id(agent_identity.as_str())))
541 }
542 MobEventKind::MemberKickoffUpdated { member, .. } => {
543 (None, None, Some(decode_member_id(member.as_str())))
544 }
545 MobEventKind::ExternalPeerWired { local, .. }
546 | MobEventKind::ExternalPeerUnwired { local, .. } => {
547 (None, None, Some(decode_member_id(local.as_str())))
548 }
549 MobEventKind::MobCreated { .. }
552 | MobEventKind::MobOwnerBridgeSessionBound { .. }
553 | MobEventKind::MobCompleted
554 | MobEventKind::MobDestroying
555 | MobEventKind::MobDestroyStorageFinalizing
556 | MobEventKind::MobReset
557 | MobEventKind::MembersWired { .. }
558 | MobEventKind::MembersWiredBatch { .. }
559 | MobEventKind::MembersUnwired { .. }
560 | MobEventKind::TopologyViolation { .. }
561 | MobEventKind::OperatorActionRecorded { .. } => (None, None, None),
562 }
563}
564
565#[cfg(test)]
566#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
567mod tests {
568 use super::*;
569 use chrono::Utc;
570 use meerkat_mob::event::{MemberSpawnedEvent, MemberWireEdge};
571 use meerkat_mob::ids::{
572 AgentIdentity, AgentRuntimeId, FenceToken, FlowId, Generation, MobId, ProfileName, RunId,
573 StepId,
574 };
575
576 fn mob_event(cursor: u64, kind: MobEventKind) -> MobEvent {
577 MobEvent {
578 cursor,
579 timestamp: Utc::now(),
580 mob_id: MobId::from("test-mob"),
581 kind,
582 }
583 }
584
585 #[tokio::test]
586 async fn projects_flow_started_with_run_id_and_upstream_cursor() {
587 let store = MobEventsStore::new();
588 let run_id = RunId::new();
589 let envelope = store
590 .project_mob_event(&mob_event(
591 42,
592 MobEventKind::FlowStarted {
593 run_id: run_id.clone(),
594 flow_id: FlowId::from("flow-a"),
595 params: serde_json::json!({}),
596 },
597 ))
598 .await;
599 assert_eq!(envelope.kind, "flow_started");
600 assert_eq!(envelope.cursor, 42);
601 assert_eq!(envelope.event_id, "mob-evt-42");
602 assert_eq!(
603 envelope.run_id.as_deref(),
604 Some(run_id.to_string().as_str())
605 );
606 assert_eq!(envelope.step_id, None);
607 assert_eq!(envelope.mob_id, "test-mob");
608 }
609
610 #[tokio::test]
611 async fn projects_step_dispatched_with_run_step_target() {
612 let store = MobEventsStore::new();
613 let identity = AgentIdentity::from("worker-1");
614 let run_id = RunId::new();
615 let envelope = store
616 .project_mob_event(&mob_event(
617 7,
618 MobEventKind::StepDispatched {
619 run_id: run_id.clone(),
620 step_id: StepId::from("step-a"),
621 target: AgentRuntimeId::initial(identity),
622 },
623 ))
624 .await;
625 assert_eq!(envelope.kind, "step_dispatched");
626 assert_eq!(envelope.cursor, 7);
627 assert_eq!(
628 envelope.run_id.as_deref(),
629 Some(run_id.to_string().as_str())
630 );
631 assert_eq!(envelope.step_id.as_deref(), Some("step-a"));
632 assert_eq!(envelope.agent_identity.as_deref(), Some("worker-1"));
633 }
634
635 #[tokio::test]
636 async fn projects_member_spawned_with_identity() {
637 let store = MobEventsStore::new();
638 let identity = AgentIdentity::from("researcher");
639 let envelope = store
640 .project_mob_event(&mob_event(
641 3,
642 MobEventKind::MemberSpawned(MemberSpawnedEvent::new(
643 identity.clone(),
644 Generation::INITIAL,
645 FenceToken::new(1),
646 AgentRuntimeId::initial(identity),
647 ProfileName::from("worker"),
648 )),
649 ))
650 .await;
651 assert_eq!(envelope.kind, "member_spawned");
652 assert_eq!(envelope.agent_identity.as_deref(), Some("researcher"));
653 }
654
655 #[tokio::test]
656 async fn projects_identity_first_member_in_public_alias_space_and_filters_round_trip() {
657 let alias = "rt:review:singleton:0";
663 let encoded = crate::member_comms_id::mob_member_id_str(alias).into_owned();
664 assert_ne!(encoded, alias, "alias must actually encode for this test");
665 let identity = AgentIdentity::from(encoded.as_str());
666
667 for kind in [
668 MobEventKind::MemberSpawned(MemberSpawnedEvent::new(
669 identity.clone(),
670 Generation::INITIAL,
671 FenceToken::new(1),
672 AgentRuntimeId::initial(identity.clone()),
673 ProfileName::from("review"),
674 )),
675 MobEventKind::MemberRetired {
676 agent_identity: identity.clone(),
677 generation: Generation::INITIAL,
678 role: ProfileName::from("review"),
679 },
680 MobEventKind::StepDispatched {
681 run_id: RunId::new(),
682 step_id: StepId::from("step-a"),
683 target: AgentRuntimeId::initial(identity.clone()),
684 },
685 ] {
686 let store = MobEventsStore::new();
687 let envelope = store.project_mob_event(&mob_event(1, kind)).await;
688
689 assert_eq!(
691 envelope.agent_identity.as_deref(),
692 Some(alias),
693 "structural envelope must decode the roster id to the alias"
694 );
695 assert!(
696 !envelope
697 .agent_identity
698 .as_deref()
699 .unwrap()
700 .starts_with("mk--"),
701 "encoded mk-- id must not leak onto the public surface"
702 );
703
704 let query = EventQuery {
707 identity: Some(alias.to_string()),
708 ..EventQuery::default()
709 };
710 assert!(
711 envelope_matches(&envelope, &query),
712 "identity-filter by the public alias must match"
713 );
714 let member_query = EventQuery {
715 member_id: Some(alias.to_string()),
716 ..EventQuery::default()
717 };
718 assert!(
719 envelope_matches(&envelope, &member_query),
720 "member_id-filter by the public alias must match"
721 );
722 }
723 }
724
725 #[tokio::test]
726 async fn projects_members_wired_batch_as_compact_structural_event() {
727 let store = MobEventsStore::new();
728 let envelope = store
729 .project_mob_event(&mob_event(
730 8,
731 MobEventKind::MembersWiredBatch {
732 edges: vec![MemberWireEdge {
733 a: AgentIdentity::from("alpha"),
734 b: AgentIdentity::from("beta"),
735 }],
736 },
737 ))
738 .await;
739 assert_eq!(envelope.kind, "members_wired_batch");
740 assert_eq!(envelope.agent_identity, None);
741 assert_eq!(envelope.data["edges"][0]["a"], serde_json::json!("alpha"));
742 assert_eq!(envelope.data["edges"][0]["b"], serde_json::json!("beta"));
743 }
744
745 #[tokio::test]
746 async fn project_event_for_query_does_not_broadcast() {
747 let store = MobEventsStore::new();
748 let mut rx = store.subscribe();
749 let _ = store
750 .project_event_for_query(&mob_event(
751 1,
752 MobEventKind::FlowStarted {
753 run_id: RunId::new(),
754 flow_id: FlowId::from("flow-a"),
755 params: serde_json::json!({}),
756 },
757 ))
758 .await;
759 assert!(rx.try_recv().is_err());
762 }
763}