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 ToolArgsDelta {
245 session_id: String,
247 tool_call_id: String,
249 args_delta: String,
251 },
252
253 CompactionTriggered {
263 session_id: Option<String>,
265 source: String,
267 },
268 CalendarEventCreated {
271 uid: String,
273 title: String,
275 start: String,
277 end: String,
279 },
280 CalendarEventUpdated {
282 uid: String,
284 title: String,
286 },
287 CalendarEventDeleted {
289 uid: String,
291 title: String,
293 },
294 EmailSent {
296 subject: String,
298 message_id: String,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 template_name: Option<String>,
303 },
304
305 KnowledgePersisted {
308 session_id: String,
309 message_index: usize,
310 path: String,
311 source: String, },
313 KnowledgeRemoved {
315 session_id: String,
316 message_index: usize,
317 },
318 AskUserRequest {
322 id: String,
325 question: String,
327 options: Vec<String>,
329 },
330 PersonaCreated {
333 id: String,
335 name: String,
337 enabled: bool,
339 source: String,
341 },
342 PersonaUpdated {
344 id: String,
346 name: String,
348 source: String,
350 },
351 IntegrationInstallStarted {
357 job_id: String,
359 integration_id: String,
361 label: String,
363 },
364 IntegrationInstallProgress {
366 job_id: String,
367 integration_id: String,
368 line: String,
370 },
371 IntegrationInstallCompleted {
373 job_id: String,
374 integration_id: String,
375 command: String,
377 output: String,
378 exit_code: Option<i32>,
379 },
380 IntegrationInstallFailed {
382 job_id: String,
383 integration_id: String,
384 error: String,
385 },
386}
387
388pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
390 match event {
391 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
392 task_type: name.clone(),
393 },
394 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
395 task_type: "started".to_string(),
396 },
397 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
398 reason: if *success {
399 "completed".to_string()
400 } else {
401 "stopped".to_string()
402 },
403 },
404 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
405 reason: error.clone(),
406 },
407 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
408 detail: format!("message: {content}"),
409 },
410 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
411 detail: format!("agent_output:{output}"),
412 },
413 KernelEvent::ApprovalRequested {
414 id,
415 action,
416 resource,
417 ..
418 } => AuditAction::Other {
419 detail: format!("approval_requested:{id}:{action}:{resource}"),
420 },
421 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
422 detail: format!("approval_resolved:{id}:{approved}"),
423 },
424 KernelEvent::MemoryStored {
425 id, memory_type, ..
426 } => AuditAction::MemoryWrite {
427 entry_id: format!("{id}:{memory_type}"),
428 },
429 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
430 entry_id: format!("query:{query}:{count}results"),
431 },
432 KernelEvent::AgentGroupCreated {
433 group_id,
434 agent_count,
435 } => AuditAction::Other {
436 detail: format!("group_created:{group_id}:{agent_count}agents"),
437 },
438 KernelEvent::AgentGroupMemberCompleted {
439 group_id,
440 agent_id,
441 success,
442 } => AuditAction::Other {
443 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
444 },
445 KernelEvent::ProjectCreated {
446 project_id: _,
447 name,
448 source,
449 } => AuditAction::Other {
450 detail: format!("project_created:{name}:{source}"),
451 },
452 KernelEvent::ProjectActivated {
453 project_id: _,
454 name,
455 } => AuditAction::Other {
456 detail: format!("project_activated:{name}"),
457 },
458 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
460 detail: format!("tool_started:{tool_name}"),
461 },
462 KernelEvent::ToolExecutionFinished {
463 tool_name,
464 is_error,
465 ..
466 } => AuditAction::Other {
467 detail: format!(
468 "tool_finished:{tool_name}:{}",
469 if *is_error { "error" } else { "ok" }
470 ),
471 },
472 KernelEvent::ToolExecutionProgress {
473 tool_name,
474 tab_id,
475 context,
476 ..
477 } => AuditAction::Other {
478 detail: {
479 let mut d = format!("tool_progress:{tool_name}");
480 if let Some(id) = tab_id {
481 d.push_str(&format!(":tab={id}"));
482 }
483 if let Some(ctx) = context
484 .as_ref()
485 .and_then(|c| c.get("kind"))
486 .and_then(|k| k.as_str())
487 {
488 d.push_str(&format!(":{ctx}"));
489 }
490 d
491 },
492 },
493 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
494 entry_id: format!("recall:{query}:{count}results"),
495 },
496 KernelEvent::TokenUsageUpdate {
497 input_tokens,
498 output_tokens,
499 ..
500 } => AuditAction::Other {
501 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
502 },
503 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
504 detail: format!("reasoning:{source}"),
505 },
506 KernelEvent::ToolArgsDelta { tool_call_id, .. } => AuditAction::Other {
507 detail: format!("tool_args_delta:{tool_call_id}"),
508 },
509 KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
510 detail: format!("compaction:triggered:{source}"),
511 },
512 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
513 detail: format!("calendar:created:{uid}:{title}"),
514 },
515 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
516 detail: format!("calendar:updated:{uid}:{title}"),
517 },
518 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
519 detail: format!("calendar:deleted:{uid}:{title}"),
520 },
521 KernelEvent::EmailSent {
522 subject,
523 message_id,
524 template_name,
525 } => AuditAction::Other {
526 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
527 },
528 KernelEvent::KnowledgePersisted {
529 session_id,
530 message_index,
531 path,
532 source,
533 } => AuditAction::Other {
534 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
535 },
536 KernelEvent::KnowledgeRemoved {
537 session_id,
538 message_index,
539 } => AuditAction::Other {
540 detail: format!("knowledge:removed:{session_id}:{message_index}"),
541 },
542 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
543 detail: format!("ask_user:{id}:{question}"),
544 },
545 KernelEvent::PersonaCreated {
546 id, name, source, ..
547 } => AuditAction::Other {
548 detail: format!("persona:created:{id}:{name}:{source}"),
549 },
550 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
551 detail: format!("persona:updated:{id}:{name}:{source}"),
552 },
553 KernelEvent::IntegrationInstallStarted {
554 job_id,
555 integration_id,
556 ..
557 } => AuditAction::Other {
558 detail: format!("install:started:{integration_id}:{job_id}"),
559 },
560 KernelEvent::IntegrationInstallProgress {
561 job_id,
562 integration_id,
563 ..
564 } => AuditAction::Other {
565 detail: format!("install:progress:{integration_id}:{job_id}"),
566 },
567 KernelEvent::IntegrationInstallCompleted {
568 job_id,
569 integration_id,
570 exit_code,
571 ..
572 } => AuditAction::Other {
573 detail: format!(
574 "install:completed:{integration_id}:{job_id}:exit={}",
575 exit_code
576 .map(|c| c.to_string())
577 .unwrap_or_else(|| "?".into())
578 ),
579 },
580 KernelEvent::IntegrationInstallFailed {
581 job_id,
582 integration_id,
583 ..
584 } => AuditAction::Other {
585 detail: format!("install:failed:{integration_id}:{job_id}"),
586 },
587 }
588}
589
590fn extract_agent_id(event: &KernelEvent) -> String {
592 match event {
593 KernelEvent::AgentCreated { id, .. } => id.to_string(),
594 KernelEvent::AgentStarted { id, .. } => id.to_string(),
595 KernelEvent::AgentStopped { id, .. } => id.to_string(),
596 KernelEvent::AgentFailed { id, .. } => id.to_string(),
597 KernelEvent::MessageReceived { from, .. } => from.to_string(),
598 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
599 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
600 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
601 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
603 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
604 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
605 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
606 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
607 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
608 KernelEvent::ToolArgsDelta { session_id, .. } => format!("session:{session_id}"),
609 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
610 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
611 KernelEvent::CompactionTriggered { session_id, .. } => session_id
612 .as_ref()
613 .map(|s| format!("session:{s}"))
614 .unwrap_or_else(|| "system".to_string()),
615 _ => "system".to_string(),
616 }
617}
618
619pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
625 let mut rx = bus.subscribe();
626 tokio::spawn(async move {
627 loop {
628 match rx.recv().await {
629 Ok(event) => {
630 if matches!(event, KernelEvent::ToolArgsDelta { .. }) {
635 continue;
636 }
637 let actor = extract_agent_id(&event);
638 let action = kernel_event_to_audit_action(&event);
639 let resource = format!("{event:?}");
640 audit.append(actor, action, resource);
641 }
642 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
643 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
647 tracing::warn!(
648 skipped = n,
649 "Audit trail subscriber lagged, skipping events"
650 );
651 continue;
652 }
653 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
654 tracing::info!("Audit trail event bus closed, exiting");
655 break;
656 }
657 }
658 }
659 });
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn sample_event(name: &str) -> KernelEvent {
667 KernelEvent::AgentCreated {
668 id: AgentId::new_v4(),
669 name: name.to_string(),
670 }
671 }
672
673 #[test]
674 fn test_event_bus_uses_sdk() {
675 let bus: EventBus = EventBus::new(256);
676 assert!(format!("{:?}", bus).contains("EventBus"));
677 }
678
679 #[tokio::test]
680 async fn test_publish_no_subscribers_ok() {
681 let bus = EventBus::new(16);
682 let result = bus.publish(sample_event("orphan"));
683 assert!(result.is_ok());
684 }
685
686 #[tokio::test]
687 async fn test_single_subscriber_receives_event() {
688 let bus = EventBus::new(16);
689 let mut rx = bus.subscribe();
690
691 let event = sample_event("test-agent");
692 bus.publish(event.clone()).unwrap();
693
694 let received = rx.try_recv().expect("should receive event");
695 match received {
696 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
697 _ => panic!("wrong event type"),
698 }
699 }
700
701 #[tokio::test]
702 async fn test_multiple_subscribers_receive_events() {
703 let bus = EventBus::new(16);
704 let mut rx1 = bus.subscribe();
705 let mut rx2 = bus.subscribe();
706
707 let event = sample_event("multi");
708 bus.publish(event.clone()).unwrap();
709
710 let r1 = rx1.try_recv().expect("rx1 should receive event");
711 let r2 = rx2.try_recv().expect("rx2 should receive event");
712
713 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
714 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
715 }
716
717 #[tokio::test]
718 async fn test_kernel_event_to_audit_action() {
719 let event = KernelEvent::AgentFailed {
720 id: AgentId::new_v4(),
721 error: "boom".to_string(),
722 };
723 let action = kernel_event_to_audit_action(&event);
724 match action {
725 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
726 other => panic!("expected AgentExit, got {other:?}"),
727 }
728 }
729
730 #[test]
736 fn test_rfc015_event_round_trip_json() {
737 let cases: Vec<KernelEvent> = vec![
738 KernelEvent::ToolExecutionStarted {
739 session_id: "s1".into(),
740 tool_name: "read_file".into(),
741 tool_call_id: "call_1".into(),
742 tool_args: serde_json::json!({"path": "/src/main.rs"}),
743 context: None,
744 },
745 KernelEvent::ToolExecutionFinished {
746 session_id: "s1".into(),
747 tool_call_id: "call_1".into(),
748 tool_name: "read_file".into(),
749 duration_ms: 234,
750 is_error: false,
751 output_summary: "fn main() {}".into(),
752 },
753 KernelEvent::ToolExecutionProgress {
754 session_id: "s1".into(),
755 tool_call_id: "call_1".into(),
756 tool_name: "read_file".into(),
757 progress: "reading line 42/100".into(),
758 tab_id: None,
759 context: None,
760 },
761 KernelEvent::MemoryRecallUsed {
762 session_id: "s1".into(),
763 query: "rust errors".into(),
764 count: 3,
765 source: "warm".into(),
766 },
767 KernelEvent::TokenUsageUpdate {
768 session_id: "s1".into(),
769 input_tokens: 1234,
770 output_tokens: 567,
771 },
772 KernelEvent::ReasoningFragment {
773 session_id: "s1".into(),
774 content: "compaction done".into(),
775 source: "compaction".into(),
776 },
777 ];
778 for event in cases {
779 let json = serde_json::to_string(&event).expect("serialize");
780 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
781 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
782 assert_eq!(json, json2, "round-trip should be stable");
783 }
784 }
785
786 #[test]
789 fn test_tool_execution_progress_serde_round_trip() {
790 let event = KernelEvent::ToolExecutionProgress {
791 session_id: "s-abc".into(),
792 tool_call_id: "call_42".into(),
793 tool_name: "browse".into(),
794 progress: "loading https://example.com".into(),
795 tab_id: Some(Uuid::new_v4()),
796 context: None,
797 };
798 let json = serde_json::to_string(&event).expect("serialize");
799 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
800 match back {
801 KernelEvent::ToolExecutionProgress {
802 ref session_id,
803 ref tool_call_id,
804 ref tool_name,
805 ref progress,
806 tab_id,
807 ..
808 } => {
809 assert_eq!(session_id, "s-abc");
810 assert_eq!(tool_call_id, "call_42");
811 assert_eq!(tool_name, "browse");
812 assert_eq!(progress, "loading https://example.com");
813 assert!(tab_id.is_some(), "tab_id should round-trip when present");
814 }
815 other => panic!("expected ToolExecutionProgress, got {other:?}"),
816 }
817 }
818
819 #[test]
825 fn test_tool_execution_progress_audit_action() {
826 let with_tab = KernelEvent::ToolExecutionProgress {
827 session_id: "s1".into(),
828 tool_call_id: "c1".into(),
829 tool_name: "browse".into(),
830 progress: "navigating".into(),
831 tab_id: Some(Uuid::new_v4()),
832 context: None,
833 };
834 match kernel_event_to_audit_action(&with_tab) {
835 AuditAction::Other { detail } => {
836 assert!(detail.contains("tool_progress"), "detail: {detail}");
837 assert!(detail.contains("browse"), "detail: {detail}");
838 assert!(
839 detail.contains(":tab="),
840 "detail should include tab id: {detail}"
841 );
842 }
843 other => panic!("expected Other, got {other:?}"),
844 }
845 let without_tab = KernelEvent::ToolExecutionProgress {
846 session_id: "s1".into(),
847 tool_call_id: "c1".into(),
848 tool_name: "browse".into(),
849 progress: "navigating".into(),
850 tab_id: None,
851 context: None,
852 };
853 match kernel_event_to_audit_action(&without_tab) {
854 AuditAction::Other { detail } => {
855 assert_eq!(detail, "tool_progress:browse");
856 }
857 other => panic!("expected Other, got {other:?}"),
858 }
859 }
860
861 #[test]
865 fn test_tool_execution_progress_tab_id_optional_in_serde() {
866 let legacy_json = r#"{
869 "ToolExecutionProgress": {
870 "session_id": "s-old",
871 "tool_call_id": "call_legacy",
872 "tool_name": "browse",
873 "progress": "step 1"
874 }
875 }"#;
876 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
877 match &event {
878 KernelEvent::ToolExecutionProgress {
879 session_id,
880 tool_call_id,
881 tool_name,
882 progress,
883 tab_id,
884 ..
885 } => {
886 assert_eq!(session_id, "s-old");
887 assert_eq!(tool_call_id, "call_legacy");
888 assert_eq!(tool_name, "browse");
889 assert_eq!(progress, "step 1");
890 assert!(tab_id.is_none(), "missing field should default to None");
891 }
892 other => panic!("expected ToolExecutionProgress, got {other:?}"),
893 }
894 let json = serde_json::to_string(&event).expect("serialize");
897 assert!(
898 !json.contains("tab_id"),
899 "tab_id should be omitted when None: {json}"
900 );
901 }
902
903 #[test]
907 fn test_rfc015_extract_agent_id() {
908 let event = KernelEvent::ToolExecutionStarted {
909 session_id: "abc-123".into(),
910 tool_name: "bash".into(),
911 tool_call_id: "c1".into(),
912 tool_args: serde_json::Value::Null,
913 context: None,
914 };
915 let action = kernel_event_to_audit_action(&event);
918 match action {
919 AuditAction::Other { detail } => {
920 assert!(
921 detail.contains("bash"),
922 "tool name in audit detail: {detail}"
923 );
924 }
925 other => panic!("expected Other, got {other:?}"),
926 }
927 }
928}