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 PathAccessRequested {
109 id: uuid::Uuid,
111 tool_name: String,
113 path: String,
115 mode: String,
117 agent_name: String,
119 reason: String,
121 session_id: Option<String>,
123 },
124 MemoryStored {
126 id: String,
128 memory_type: String,
130 source: String,
132 },
133 MemoryRecalled {
135 query: String,
137 count: usize,
139 },
140 AgentGroupCreated {
142 group_id: uuid::Uuid,
144 agent_count: usize,
146 },
147 AgentGroupMemberCompleted {
149 group_id: uuid::Uuid,
151 agent_id: uuid::Uuid,
153 success: bool,
155 },
156 ProjectCreated {
158 project_id: uuid::Uuid,
160 name: String,
162 source: String,
164 },
165 ProjectActivated {
167 project_id: uuid::Uuid,
169 name: String,
171 },
172
173 ToolExecutionStarted {
178 session_id: String,
180 tool_name: String,
182 tool_call_id: String,
184 tool_args: serde_json::Value,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
189 context: Option<serde_json::Value>,
190 },
191 ToolExecutionFinished {
193 session_id: String,
195 tool_call_id: String,
197 tool_name: String,
199 duration_ms: u64,
201 is_error: bool,
203 output_summary: String,
205 },
206 ToolExecutionProgress {
208 session_id: String,
210 tool_call_id: String,
212 tool_name: String,
214 progress: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 tab_id: Option<Uuid>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
227 context: Option<serde_json::Value>,
228 },
229 MemoryRecallUsed {
231 session_id: String,
233 query: String,
235 count: usize,
237 source: String,
239 },
240 TokenUsageUpdate {
242 session_id: String,
244 input_tokens: u64,
246 output_tokens: u64,
248 },
249 ReasoningFragment {
251 session_id: String,
253 content: String,
255 source: String,
257 },
258 ToolArgsDelta {
265 session_id: String,
267 tool_call_id: String,
269 args_delta: String,
271 },
272
273 CompactionTriggered {
283 session_id: Option<String>,
285 source: String,
287 },
288 CalendarEventCreated {
291 uid: String,
293 title: String,
295 start: String,
297 end: String,
299 },
300 CalendarEventUpdated {
302 uid: String,
304 title: String,
306 },
307 CalendarEventDeleted {
309 uid: String,
311 title: String,
313 },
314 EmailSent {
316 subject: String,
318 message_id: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 template_name: Option<String>,
323 },
324
325 KnowledgePersisted {
328 session_id: String,
329 message_index: usize,
330 path: String,
331 source: String, },
333 KnowledgeRemoved {
335 session_id: String,
336 message_index: usize,
337 },
338 AskUserRequest {
342 id: String,
345 question: String,
347 options: Vec<String>,
349 },
350 PersonaCreated {
353 id: String,
355 name: String,
357 enabled: bool,
359 source: String,
361 },
362 PersonaUpdated {
364 id: String,
366 name: String,
368 source: String,
370 },
371 IntegrationInstallStarted {
377 job_id: String,
379 integration_id: String,
381 label: String,
383 },
384 IntegrationInstallProgress {
386 job_id: String,
387 integration_id: String,
388 line: String,
390 },
391 IntegrationInstallCompleted {
393 job_id: String,
394 integration_id: String,
395 command: String,
397 output: String,
398 exit_code: Option<i32>,
399 },
400 IntegrationInstallFailed {
402 job_id: String,
403 integration_id: String,
404 error: String,
405 },
406}
407
408pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
410 match event {
411 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
412 task_type: name.clone(),
413 },
414 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
415 task_type: "started".to_string(),
416 },
417 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
418 reason: if *success {
419 "completed".to_string()
420 } else {
421 "stopped".to_string()
422 },
423 },
424 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
425 reason: error.clone(),
426 },
427 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
428 detail: format!("message: {content}"),
429 },
430 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
431 detail: format!("agent_output:{output}"),
432 },
433 KernelEvent::ApprovalRequested {
434 id,
435 action,
436 resource,
437 ..
438 } => AuditAction::Other {
439 detail: format!("approval_requested:{id}:{action}:{resource}"),
440 },
441 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
442 detail: format!("approval_resolved:{id}:{approved}"),
443 },
444 KernelEvent::PathAccessRequested {
445 id,
446 path,
447 tool_name,
448 ..
449 } => AuditAction::Other {
450 detail: format!("path_access_requested:{id}:{tool_name}:{path}"),
451 },
452 KernelEvent::MemoryStored {
453 id, memory_type, ..
454 } => AuditAction::MemoryWrite {
455 entry_id: format!("{id}:{memory_type}"),
456 },
457 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
458 entry_id: format!("query:{query}:{count}results"),
459 },
460 KernelEvent::AgentGroupCreated {
461 group_id,
462 agent_count,
463 } => AuditAction::Other {
464 detail: format!("group_created:{group_id}:{agent_count}agents"),
465 },
466 KernelEvent::AgentGroupMemberCompleted {
467 group_id,
468 agent_id,
469 success,
470 } => AuditAction::Other {
471 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
472 },
473 KernelEvent::ProjectCreated {
474 project_id: _,
475 name,
476 source,
477 } => AuditAction::Other {
478 detail: format!("project_created:{name}:{source}"),
479 },
480 KernelEvent::ProjectActivated {
481 project_id: _,
482 name,
483 } => AuditAction::Other {
484 detail: format!("project_activated:{name}"),
485 },
486 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
488 detail: format!("tool_started:{tool_name}"),
489 },
490 KernelEvent::ToolExecutionFinished {
491 tool_name,
492 is_error,
493 ..
494 } => AuditAction::Other {
495 detail: format!(
496 "tool_finished:{tool_name}:{}",
497 if *is_error { "error" } else { "ok" }
498 ),
499 },
500 KernelEvent::ToolExecutionProgress {
501 tool_name,
502 tab_id,
503 context,
504 ..
505 } => AuditAction::Other {
506 detail: {
507 let mut d = format!("tool_progress:{tool_name}");
508 if let Some(id) = tab_id {
509 d.push_str(&format!(":tab={id}"));
510 }
511 if let Some(ctx) = context
512 .as_ref()
513 .and_then(|c| c.get("kind"))
514 .and_then(|k| k.as_str())
515 {
516 d.push_str(&format!(":{ctx}"));
517 }
518 d
519 },
520 },
521 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
522 entry_id: format!("recall:{query}:{count}results"),
523 },
524 KernelEvent::TokenUsageUpdate {
525 input_tokens,
526 output_tokens,
527 ..
528 } => AuditAction::Other {
529 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
530 },
531 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
532 detail: format!("reasoning:{source}"),
533 },
534 KernelEvent::ToolArgsDelta { tool_call_id, .. } => AuditAction::Other {
535 detail: format!("tool_args_delta:{tool_call_id}"),
536 },
537 KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
538 detail: format!("compaction:triggered:{source}"),
539 },
540 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
541 detail: format!("calendar:created:{uid}:{title}"),
542 },
543 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
544 detail: format!("calendar:updated:{uid}:{title}"),
545 },
546 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
547 detail: format!("calendar:deleted:{uid}:{title}"),
548 },
549 KernelEvent::EmailSent {
550 subject,
551 message_id,
552 template_name,
553 } => AuditAction::Other {
554 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
555 },
556 KernelEvent::KnowledgePersisted {
557 session_id,
558 message_index,
559 path,
560 source,
561 } => AuditAction::Other {
562 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
563 },
564 KernelEvent::KnowledgeRemoved {
565 session_id,
566 message_index,
567 } => AuditAction::Other {
568 detail: format!("knowledge:removed:{session_id}:{message_index}"),
569 },
570 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
571 detail: format!("ask_user:{id}:{question}"),
572 },
573 KernelEvent::PersonaCreated {
574 id, name, source, ..
575 } => AuditAction::Other {
576 detail: format!("persona:created:{id}:{name}:{source}"),
577 },
578 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
579 detail: format!("persona:updated:{id}:{name}:{source}"),
580 },
581 KernelEvent::IntegrationInstallStarted {
582 job_id,
583 integration_id,
584 ..
585 } => AuditAction::Other {
586 detail: format!("install:started:{integration_id}:{job_id}"),
587 },
588 KernelEvent::IntegrationInstallProgress {
589 job_id,
590 integration_id,
591 ..
592 } => AuditAction::Other {
593 detail: format!("install:progress:{integration_id}:{job_id}"),
594 },
595 KernelEvent::IntegrationInstallCompleted {
596 job_id,
597 integration_id,
598 exit_code,
599 ..
600 } => AuditAction::Other {
601 detail: format!(
602 "install:completed:{integration_id}:{job_id}:exit={}",
603 exit_code
604 .map(|c| c.to_string())
605 .unwrap_or_else(|| "?".into())
606 ),
607 },
608 KernelEvent::IntegrationInstallFailed {
609 job_id,
610 integration_id,
611 ..
612 } => AuditAction::Other {
613 detail: format!("install:failed:{integration_id}:{job_id}"),
614 },
615 }
616}
617
618fn extract_agent_id(event: &KernelEvent) -> String {
620 match event {
621 KernelEvent::AgentCreated { id, .. } => id.to_string(),
622 KernelEvent::AgentStarted { id, .. } => id.to_string(),
623 KernelEvent::AgentStopped { id, .. } => id.to_string(),
624 KernelEvent::AgentFailed { id, .. } => id.to_string(),
625 KernelEvent::MessageReceived { from, .. } => from.to_string(),
626 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
627 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
628 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
629 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
631 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
632 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
633 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
634 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
635 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
636 KernelEvent::ToolArgsDelta { session_id, .. } => format!("session:{session_id}"),
637 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
638 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
639 KernelEvent::CompactionTriggered { session_id, .. } => session_id
640 .as_ref()
641 .map(|s| format!("session:{s}"))
642 .unwrap_or_else(|| "system".to_string()),
643 _ => "system".to_string(),
644 }
645}
646
647pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
653 let mut rx = bus.subscribe();
654 tokio::spawn(async move {
655 loop {
656 match rx.recv().await {
657 Ok(event) => {
658 if matches!(event, KernelEvent::ToolArgsDelta { .. }) {
663 continue;
664 }
665 let actor = extract_agent_id(&event);
666 let action = kernel_event_to_audit_action(&event);
667 let resource = format!("{event:?}");
668 audit.append(actor, action, resource);
669 }
670 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
671 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
675 tracing::warn!(
676 skipped = n,
677 "Audit trail subscriber lagged, skipping events"
678 );
679 continue;
680 }
681 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
682 tracing::info!("Audit trail event bus closed, exiting");
683 break;
684 }
685 }
686 }
687 });
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 fn sample_event(name: &str) -> KernelEvent {
695 KernelEvent::AgentCreated {
696 id: AgentId::new_v4(),
697 name: name.to_string(),
698 }
699 }
700
701 #[test]
702 fn test_event_bus_uses_sdk() {
703 let bus: EventBus = EventBus::new(256);
704 assert!(format!("{:?}", bus).contains("EventBus"));
705 }
706
707 #[tokio::test]
708 async fn test_publish_no_subscribers_ok() {
709 let bus = EventBus::new(16);
710 let result = bus.publish(sample_event("orphan"));
711 assert!(result.is_ok());
712 }
713
714 #[tokio::test]
715 async fn test_single_subscriber_receives_event() {
716 let bus = EventBus::new(16);
717 let mut rx = bus.subscribe();
718
719 let event = sample_event("test-agent");
720 bus.publish(event.clone()).unwrap();
721
722 let received = rx.try_recv().expect("should receive event");
723 match received {
724 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
725 _ => panic!("wrong event type"),
726 }
727 }
728
729 #[tokio::test]
730 async fn test_multiple_subscribers_receive_events() {
731 let bus = EventBus::new(16);
732 let mut rx1 = bus.subscribe();
733 let mut rx2 = bus.subscribe();
734
735 let event = sample_event("multi");
736 bus.publish(event.clone()).unwrap();
737
738 let r1 = rx1.try_recv().expect("rx1 should receive event");
739 let r2 = rx2.try_recv().expect("rx2 should receive event");
740
741 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
742 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
743 }
744
745 #[tokio::test]
746 async fn test_kernel_event_to_audit_action() {
747 let event = KernelEvent::AgentFailed {
748 id: AgentId::new_v4(),
749 error: "boom".to_string(),
750 };
751 let action = kernel_event_to_audit_action(&event);
752 match action {
753 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
754 other => panic!("expected AgentExit, got {other:?}"),
755 }
756 }
757
758 #[test]
764 fn test_rfc015_event_round_trip_json() {
765 let cases: Vec<KernelEvent> = vec![
766 KernelEvent::ToolExecutionStarted {
767 session_id: "s1".into(),
768 tool_name: "read_file".into(),
769 tool_call_id: "call_1".into(),
770 tool_args: serde_json::json!({"path": "/src/main.rs"}),
771 context: None,
772 },
773 KernelEvent::ToolExecutionFinished {
774 session_id: "s1".into(),
775 tool_call_id: "call_1".into(),
776 tool_name: "read_file".into(),
777 duration_ms: 234,
778 is_error: false,
779 output_summary: "fn main() {}".into(),
780 },
781 KernelEvent::ToolExecutionProgress {
782 session_id: "s1".into(),
783 tool_call_id: "call_1".into(),
784 tool_name: "read_file".into(),
785 progress: "reading line 42/100".into(),
786 tab_id: None,
787 context: None,
788 },
789 KernelEvent::MemoryRecallUsed {
790 session_id: "s1".into(),
791 query: "rust errors".into(),
792 count: 3,
793 source: "warm".into(),
794 },
795 KernelEvent::TokenUsageUpdate {
796 session_id: "s1".into(),
797 input_tokens: 1234,
798 output_tokens: 567,
799 },
800 KernelEvent::ReasoningFragment {
801 session_id: "s1".into(),
802 content: "compaction done".into(),
803 source: "compaction".into(),
804 },
805 ];
806 for event in cases {
807 let json = serde_json::to_string(&event).expect("serialize");
808 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
809 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
810 assert_eq!(json, json2, "round-trip should be stable");
811 }
812 }
813
814 #[test]
817 fn test_tool_execution_progress_serde_round_trip() {
818 let event = KernelEvent::ToolExecutionProgress {
819 session_id: "s-abc".into(),
820 tool_call_id: "call_42".into(),
821 tool_name: "browse".into(),
822 progress: "loading https://example.com".into(),
823 tab_id: Some(Uuid::new_v4()),
824 context: None,
825 };
826 let json = serde_json::to_string(&event).expect("serialize");
827 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
828 match back {
829 KernelEvent::ToolExecutionProgress {
830 ref session_id,
831 ref tool_call_id,
832 ref tool_name,
833 ref progress,
834 tab_id,
835 ..
836 } => {
837 assert_eq!(session_id, "s-abc");
838 assert_eq!(tool_call_id, "call_42");
839 assert_eq!(tool_name, "browse");
840 assert_eq!(progress, "loading https://example.com");
841 assert!(tab_id.is_some(), "tab_id should round-trip when present");
842 }
843 other => panic!("expected ToolExecutionProgress, got {other:?}"),
844 }
845 }
846
847 #[test]
853 fn test_tool_execution_progress_audit_action() {
854 let with_tab = KernelEvent::ToolExecutionProgress {
855 session_id: "s1".into(),
856 tool_call_id: "c1".into(),
857 tool_name: "browse".into(),
858 progress: "navigating".into(),
859 tab_id: Some(Uuid::new_v4()),
860 context: None,
861 };
862 match kernel_event_to_audit_action(&with_tab) {
863 AuditAction::Other { detail } => {
864 assert!(detail.contains("tool_progress"), "detail: {detail}");
865 assert!(detail.contains("browse"), "detail: {detail}");
866 assert!(
867 detail.contains(":tab="),
868 "detail should include tab id: {detail}"
869 );
870 }
871 other => panic!("expected Other, got {other:?}"),
872 }
873 let without_tab = KernelEvent::ToolExecutionProgress {
874 session_id: "s1".into(),
875 tool_call_id: "c1".into(),
876 tool_name: "browse".into(),
877 progress: "navigating".into(),
878 tab_id: None,
879 context: None,
880 };
881 match kernel_event_to_audit_action(&without_tab) {
882 AuditAction::Other { detail } => {
883 assert_eq!(detail, "tool_progress:browse");
884 }
885 other => panic!("expected Other, got {other:?}"),
886 }
887 }
888
889 #[test]
893 fn test_tool_execution_progress_tab_id_optional_in_serde() {
894 let legacy_json = r#"{
897 "ToolExecutionProgress": {
898 "session_id": "s-old",
899 "tool_call_id": "call_legacy",
900 "tool_name": "browse",
901 "progress": "step 1"
902 }
903 }"#;
904 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
905 match &event {
906 KernelEvent::ToolExecutionProgress {
907 session_id,
908 tool_call_id,
909 tool_name,
910 progress,
911 tab_id,
912 ..
913 } => {
914 assert_eq!(session_id, "s-old");
915 assert_eq!(tool_call_id, "call_legacy");
916 assert_eq!(tool_name, "browse");
917 assert_eq!(progress, "step 1");
918 assert!(tab_id.is_none(), "missing field should default to None");
919 }
920 other => panic!("expected ToolExecutionProgress, got {other:?}"),
921 }
922 let json = serde_json::to_string(&event).expect("serialize");
925 assert!(
926 !json.contains("tab_id"),
927 "tab_id should be omitted when None: {json}"
928 );
929 }
930
931 #[test]
935 fn test_rfc015_extract_agent_id() {
936 let event = KernelEvent::ToolExecutionStarted {
937 session_id: "abc-123".into(),
938 tool_name: "bash".into(),
939 tool_call_id: "c1".into(),
940 tool_args: serde_json::Value::Null,
941 context: None,
942 };
943 let action = kernel_event_to_audit_action(&event);
946 match action {
947 AuditAction::Other { detail } => {
948 assert!(
949 detail.contains("bash"),
950 "tool name in audit detail: {detail}"
951 );
952 }
953 other => panic!("expected Other, got {other:?}"),
954 }
955 }
956}