1use oxi_sdk::EventBus as SdkEventBus;
15use oxi_sdk::observability::{AuditAction, AuditTrail};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18use uuid::Uuid;
19
20use crate::types::AgentId;
21
22pub type EventBus = SdkEventBus<KernelEvent>;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum KernelEvent {
32 AgentCreated {
34 id: AgentId,
36 name: String,
38 },
39 AgentStarted {
41 id: AgentId,
43 },
44 AgentStopped {
51 id: AgentId,
53 #[serde(default)]
57 success: bool,
58 },
59 AgentFailed {
61 id: AgentId,
63 error: String,
65 },
66 MessageReceived {
68 from: AgentId,
70 content: String,
72 },
73 AgentOutput {
75 session_id: String,
77 agent_id: AgentId,
79 output: String,
81 },
82 ApprovalRequested {
84 id: uuid::Uuid,
86 tool_name: String,
88 action: String,
90 resource: String,
92 reason: String,
94 session_id: Option<String>,
96 },
97 ApprovalResolved {
99 id: uuid::Uuid,
101 approved: bool,
103 },
104 MemoryStored {
106 id: String,
108 memory_type: String,
110 source: String,
112 },
113 MemoryRecalled {
115 query: String,
117 count: usize,
119 },
120 AgentGroupCreated {
122 group_id: uuid::Uuid,
124 agent_count: usize,
126 },
127 AgentGroupMemberCompleted {
129 group_id: uuid::Uuid,
131 agent_id: uuid::Uuid,
133 success: bool,
135 },
136 ProjectCreated {
138 project_id: uuid::Uuid,
140 name: String,
142 source: String,
144 },
145 ProjectActivated {
147 project_id: uuid::Uuid,
149 name: String,
151 },
152
153 ToolExecutionStarted {
158 session_id: String,
160 tool_name: String,
162 tool_call_id: String,
164 tool_args: serde_json::Value,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 context: Option<serde_json::Value>,
170 },
171 ToolExecutionFinished {
173 session_id: String,
175 tool_call_id: String,
177 tool_name: String,
179 duration_ms: u64,
181 is_error: bool,
183 output_summary: String,
185 },
186 ToolExecutionProgress {
188 session_id: String,
190 tool_call_id: String,
192 tool_name: String,
194 progress: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 tab_id: Option<Uuid>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
207 context: Option<serde_json::Value>,
208 },
209 MemoryRecallUsed {
211 session_id: String,
213 query: String,
215 count: usize,
217 source: String,
219 },
220 TokenUsageUpdate {
222 session_id: String,
224 input_tokens: u64,
226 output_tokens: u64,
228 },
229 ReasoningFragment {
231 session_id: String,
233 content: String,
235 source: String,
237 },
238
239 CompactionTriggered {
249 session_id: Option<String>,
251 source: String,
253 },
254 CalendarEventCreated {
257 uid: String,
259 title: String,
261 start: String,
263 end: String,
265 },
266 CalendarEventUpdated {
268 uid: String,
270 title: String,
272 },
273 CalendarEventDeleted {
275 uid: String,
277 title: String,
279 },
280 EmailSent {
282 subject: String,
284 message_id: String,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 template_name: Option<String>,
289 },
290
291 KnowledgePersisted {
294 session_id: String,
295 message_index: usize,
296 path: String,
297 source: String, },
299 KnowledgeRemoved {
301 session_id: String,
302 message_index: usize,
303 },
304 AskUserRequest {
308 id: String,
311 question: String,
313 options: Vec<String>,
315 },
316 PersonaCreated {
319 id: String,
321 name: String,
323 enabled: bool,
325 source: String,
327 },
328 PersonaUpdated {
330 id: String,
332 name: String,
334 source: String,
336 },
337}
338
339pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
341 match event {
342 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
343 task_type: name.clone(),
344 },
345 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
346 task_type: "started".to_string(),
347 },
348 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
349 reason: if *success {
350 "completed".to_string()
351 } else {
352 "stopped".to_string()
353 },
354 },
355 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
356 reason: error.clone(),
357 },
358 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
359 detail: format!("message: {content}"),
360 },
361 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
362 detail: format!("agent_output:{output}"),
363 },
364 KernelEvent::ApprovalRequested {
365 id,
366 action,
367 resource,
368 ..
369 } => AuditAction::Other {
370 detail: format!("approval_requested:{id}:{action}:{resource}"),
371 },
372 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
373 detail: format!("approval_resolved:{id}:{approved}"),
374 },
375 KernelEvent::MemoryStored {
376 id, memory_type, ..
377 } => AuditAction::MemoryWrite {
378 entry_id: format!("{id}:{memory_type}"),
379 },
380 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
381 entry_id: format!("query:{query}:{count}results"),
382 },
383 KernelEvent::AgentGroupCreated {
384 group_id,
385 agent_count,
386 } => AuditAction::Other {
387 detail: format!("group_created:{group_id}:{agent_count}agents"),
388 },
389 KernelEvent::AgentGroupMemberCompleted {
390 group_id,
391 agent_id,
392 success,
393 } => AuditAction::Other {
394 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
395 },
396 KernelEvent::ProjectCreated {
397 project_id: _,
398 name,
399 source,
400 } => AuditAction::Other {
401 detail: format!("project_created:{name}:{source}"),
402 },
403 KernelEvent::ProjectActivated {
404 project_id: _,
405 name,
406 } => AuditAction::Other {
407 detail: format!("project_activated:{name}"),
408 },
409 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
411 detail: format!("tool_started:{tool_name}"),
412 },
413 KernelEvent::ToolExecutionFinished {
414 tool_name,
415 is_error,
416 ..
417 } => AuditAction::Other {
418 detail: format!(
419 "tool_finished:{tool_name}:{}",
420 if *is_error { "error" } else { "ok" }
421 ),
422 },
423 KernelEvent::ToolExecutionProgress {
424 tool_name,
425 tab_id,
426 context,
427 ..
428 } => AuditAction::Other {
429 detail: {
430 let mut d = format!("tool_progress:{tool_name}");
431 if let Some(id) = tab_id {
432 d.push_str(&format!(":tab={id}"));
433 }
434 if let Some(ctx) = context
435 .as_ref()
436 .and_then(|c| c.get("kind"))
437 .and_then(|k| k.as_str())
438 {
439 d.push_str(&format!(":{ctx}"));
440 }
441 d
442 },
443 },
444 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
445 entry_id: format!("recall:{query}:{count}results"),
446 },
447 KernelEvent::TokenUsageUpdate {
448 input_tokens,
449 output_tokens,
450 ..
451 } => AuditAction::Other {
452 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
453 },
454 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
455 detail: format!("reasoning:{source}"),
456 },
457 KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
458 detail: format!("compaction:triggered:{source}"),
459 },
460 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
461 detail: format!("calendar:created:{uid}:{title}"),
462 },
463 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
464 detail: format!("calendar:updated:{uid}:{title}"),
465 },
466 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
467 detail: format!("calendar:deleted:{uid}:{title}"),
468 },
469 KernelEvent::EmailSent {
470 subject,
471 message_id,
472 template_name,
473 } => AuditAction::Other {
474 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
475 },
476 KernelEvent::KnowledgePersisted {
477 session_id,
478 message_index,
479 path,
480 source,
481 } => AuditAction::Other {
482 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
483 },
484 KernelEvent::KnowledgeRemoved {
485 session_id,
486 message_index,
487 } => AuditAction::Other {
488 detail: format!("knowledge:removed:{session_id}:{message_index}"),
489 },
490 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
491 detail: format!("ask_user:{id}:{question}"),
492 },
493 KernelEvent::PersonaCreated {
494 id, name, source, ..
495 } => AuditAction::Other {
496 detail: format!("persona:created:{id}:{name}:{source}"),
497 },
498 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
499 detail: format!("persona:updated:{id}:{name}:{source}"),
500 },
501 }
502}
503
504fn extract_agent_id(event: &KernelEvent) -> String {
506 match event {
507 KernelEvent::AgentCreated { id, .. } => id.to_string(),
508 KernelEvent::AgentStarted { id, .. } => id.to_string(),
509 KernelEvent::AgentStopped { id, .. } => id.to_string(),
510 KernelEvent::AgentFailed { id, .. } => id.to_string(),
511 KernelEvent::MessageReceived { from, .. } => from.to_string(),
512 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
513 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
514 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
515 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
517 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
518 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
519 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
520 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
521 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
522 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
523 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
524 KernelEvent::CompactionTriggered { session_id, .. } => session_id
525 .as_ref()
526 .map(|s| format!("session:{s}"))
527 .unwrap_or_else(|| "system".to_string()),
528 _ => "system".to_string(),
529 }
530}
531
532pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
538 let mut rx = bus.subscribe();
539 tokio::spawn(async move {
540 loop {
541 match rx.recv().await {
542 Ok(event) => {
543 let actor = extract_agent_id(&event);
544 let action = kernel_event_to_audit_action(&event);
545 let resource = format!("{event:?}");
546 audit.append(actor, action, resource);
547 }
548 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
549 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
553 tracing::warn!(
554 skipped = n,
555 "Audit trail subscriber lagged, skipping events"
556 );
557 continue;
558 }
559 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
560 tracing::info!("Audit trail event bus closed, exiting");
561 break;
562 }
563 }
564 }
565 });
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn sample_event(name: &str) -> KernelEvent {
573 KernelEvent::AgentCreated {
574 id: AgentId::new_v4(),
575 name: name.to_string(),
576 }
577 }
578
579 #[test]
580 fn test_event_bus_uses_sdk() {
581 let bus: EventBus = EventBus::new(256);
582 assert!(format!("{:?}", bus).contains("EventBus"));
583 }
584
585 #[tokio::test]
586 async fn test_publish_no_subscribers_ok() {
587 let bus = EventBus::new(16);
588 let result = bus.publish(sample_event("orphan"));
589 assert!(result.is_ok());
590 }
591
592 #[tokio::test]
593 async fn test_single_subscriber_receives_event() {
594 let bus = EventBus::new(16);
595 let mut rx = bus.subscribe();
596
597 let event = sample_event("test-agent");
598 bus.publish(event.clone()).unwrap();
599
600 let received = rx.try_recv().expect("should receive event");
601 match received {
602 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
603 _ => panic!("wrong event type"),
604 }
605 }
606
607 #[tokio::test]
608 async fn test_multiple_subscribers_receive_events() {
609 let bus = EventBus::new(16);
610 let mut rx1 = bus.subscribe();
611 let mut rx2 = bus.subscribe();
612
613 let event = sample_event("multi");
614 bus.publish(event.clone()).unwrap();
615
616 let r1 = rx1.try_recv().expect("rx1 should receive event");
617 let r2 = rx2.try_recv().expect("rx2 should receive event");
618
619 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
620 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
621 }
622
623 #[tokio::test]
624 async fn test_kernel_event_to_audit_action() {
625 let event = KernelEvent::AgentFailed {
626 id: AgentId::new_v4(),
627 error: "boom".to_string(),
628 };
629 let action = kernel_event_to_audit_action(&event);
630 match action {
631 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
632 other => panic!("expected AgentExit, got {other:?}"),
633 }
634 }
635
636 #[test]
642 fn test_rfc015_event_round_trip_json() {
643 let cases: Vec<KernelEvent> = vec![
644 KernelEvent::ToolExecutionStarted {
645 session_id: "s1".into(),
646 tool_name: "read_file".into(),
647 tool_call_id: "call_1".into(),
648 tool_args: serde_json::json!({"path": "/src/main.rs"}),
649 context: None,
650 },
651 KernelEvent::ToolExecutionFinished {
652 session_id: "s1".into(),
653 tool_call_id: "call_1".into(),
654 tool_name: "read_file".into(),
655 duration_ms: 234,
656 is_error: false,
657 output_summary: "fn main() {}".into(),
658 },
659 KernelEvent::ToolExecutionProgress {
660 session_id: "s1".into(),
661 tool_call_id: "call_1".into(),
662 tool_name: "read_file".into(),
663 progress: "reading line 42/100".into(),
664 tab_id: None,
665 context: None,
666 },
667 KernelEvent::MemoryRecallUsed {
668 session_id: "s1".into(),
669 query: "rust errors".into(),
670 count: 3,
671 source: "warm".into(),
672 },
673 KernelEvent::TokenUsageUpdate {
674 session_id: "s1".into(),
675 input_tokens: 1234,
676 output_tokens: 567,
677 },
678 KernelEvent::ReasoningFragment {
679 session_id: "s1".into(),
680 content: "compaction done".into(),
681 source: "compaction".into(),
682 },
683 ];
684 for event in cases {
685 let json = serde_json::to_string(&event).expect("serialize");
686 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
687 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
688 assert_eq!(json, json2, "round-trip should be stable");
689 }
690 }
691
692 #[test]
695 fn test_tool_execution_progress_serde_round_trip() {
696 let event = KernelEvent::ToolExecutionProgress {
697 session_id: "s-abc".into(),
698 tool_call_id: "call_42".into(),
699 tool_name: "browse".into(),
700 progress: "loading https://example.com".into(),
701 tab_id: Some(Uuid::new_v4()),
702 context: None,
703 };
704 let json = serde_json::to_string(&event).expect("serialize");
705 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
706 match back {
707 KernelEvent::ToolExecutionProgress {
708 ref session_id,
709 ref tool_call_id,
710 ref tool_name,
711 ref progress,
712 tab_id,
713 ..
714 } => {
715 assert_eq!(session_id, "s-abc");
716 assert_eq!(tool_call_id, "call_42");
717 assert_eq!(tool_name, "browse");
718 assert_eq!(progress, "loading https://example.com");
719 assert!(tab_id.is_some(), "tab_id should round-trip when present");
720 }
721 other => panic!("expected ToolExecutionProgress, got {other:?}"),
722 }
723 }
724
725 #[test]
731 fn test_tool_execution_progress_audit_action() {
732 let with_tab = KernelEvent::ToolExecutionProgress {
733 session_id: "s1".into(),
734 tool_call_id: "c1".into(),
735 tool_name: "browse".into(),
736 progress: "navigating".into(),
737 tab_id: Some(Uuid::new_v4()),
738 context: None,
739 };
740 match kernel_event_to_audit_action(&with_tab) {
741 AuditAction::Other { detail } => {
742 assert!(detail.contains("tool_progress"), "detail: {detail}");
743 assert!(detail.contains("browse"), "detail: {detail}");
744 assert!(
745 detail.contains(":tab="),
746 "detail should include tab id: {detail}"
747 );
748 }
749 other => panic!("expected Other, got {other:?}"),
750 }
751 let without_tab = KernelEvent::ToolExecutionProgress {
752 session_id: "s1".into(),
753 tool_call_id: "c1".into(),
754 tool_name: "browse".into(),
755 progress: "navigating".into(),
756 tab_id: None,
757 context: None,
758 };
759 match kernel_event_to_audit_action(&without_tab) {
760 AuditAction::Other { detail } => {
761 assert_eq!(detail, "tool_progress:browse");
762 }
763 other => panic!("expected Other, got {other:?}"),
764 }
765 }
766
767 #[test]
771 fn test_tool_execution_progress_tab_id_optional_in_serde() {
772 let legacy_json = r#"{
775 "ToolExecutionProgress": {
776 "session_id": "s-old",
777 "tool_call_id": "call_legacy",
778 "tool_name": "browse",
779 "progress": "step 1"
780 }
781 }"#;
782 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
783 match &event {
784 KernelEvent::ToolExecutionProgress {
785 session_id,
786 tool_call_id,
787 tool_name,
788 progress,
789 tab_id,
790 ..
791 } => {
792 assert_eq!(session_id, "s-old");
793 assert_eq!(tool_call_id, "call_legacy");
794 assert_eq!(tool_name, "browse");
795 assert_eq!(progress, "step 1");
796 assert!(tab_id.is_none(), "missing field should default to None");
797 }
798 other => panic!("expected ToolExecutionProgress, got {other:?}"),
799 }
800 let json = serde_json::to_string(&event).expect("serialize");
803 assert!(
804 !json.contains("tab_id"),
805 "tab_id should be omitted when None: {json}"
806 );
807 }
808
809 #[test]
813 fn test_rfc015_extract_agent_id() {
814 let event = KernelEvent::ToolExecutionStarted {
815 session_id: "abc-123".into(),
816 tool_name: "bash".into(),
817 tool_call_id: "c1".into(),
818 tool_args: serde_json::Value::Null,
819 context: None,
820 };
821 let action = kernel_event_to_audit_action(&event);
824 match action {
825 AuditAction::Other { detail } => {
826 assert!(
827 detail.contains("bash"),
828 "tool name in audit detail: {detail}"
829 );
830 }
831 other => panic!("expected Other, got {other:?}"),
832 }
833 }
834}