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 PhaseStarted {
261 session_id: String,
263 phase: String,
265 summary: Option<String>,
267 },
268 PhaseCompleted {
270 session_id: String,
272 phase: String,
274 },
275
276 CalendarEventCreated {
279 uid: String,
281 title: String,
283 start: String,
285 end: String,
287 },
288 CalendarEventUpdated {
290 uid: String,
292 title: String,
294 },
295 CalendarEventDeleted {
297 uid: String,
299 title: String,
301 },
302 EmailSent {
304 subject: String,
306 message_id: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 template_name: Option<String>,
311 },
312
313 KnowledgePersisted {
316 session_id: String,
317 message_index: usize,
318 path: String,
319 source: String, },
321 KnowledgeRemoved {
323 session_id: String,
324 message_index: usize,
325 },
326 AskUserRequest {
330 id: String,
333 question: String,
335 options: Vec<String>,
337 },
338 PersonaCreated {
341 id: String,
343 name: String,
345 enabled: bool,
347 source: String,
349 },
350 PersonaUpdated {
352 id: String,
354 name: String,
356 source: String,
358 },
359}
360
361pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
363 match event {
364 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
365 task_type: name.clone(),
366 },
367 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
368 task_type: "started".to_string(),
369 },
370 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
371 reason: if *success {
372 "completed".to_string()
373 } else {
374 "stopped".to_string()
375 },
376 },
377 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
378 reason: error.clone(),
379 },
380 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
381 detail: format!("message: {content}"),
382 },
383 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
384 detail: format!("agent_output:{output}"),
385 },
386 KernelEvent::ApprovalRequested {
387 id,
388 action,
389 resource,
390 ..
391 } => AuditAction::Other {
392 detail: format!("approval_requested:{id}:{action}:{resource}"),
393 },
394 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
395 detail: format!("approval_resolved:{id}:{approved}"),
396 },
397 KernelEvent::MemoryStored {
398 id, memory_type, ..
399 } => AuditAction::MemoryWrite {
400 entry_id: format!("{id}:{memory_type}"),
401 },
402 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
403 entry_id: format!("query:{query}:{count}results"),
404 },
405 KernelEvent::AgentGroupCreated {
406 group_id,
407 agent_count,
408 } => AuditAction::Other {
409 detail: format!("group_created:{group_id}:{agent_count}agents"),
410 },
411 KernelEvent::AgentGroupMemberCompleted {
412 group_id,
413 agent_id,
414 success,
415 } => AuditAction::Other {
416 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
417 },
418 KernelEvent::ProjectCreated {
419 project_id: _,
420 name,
421 source,
422 } => AuditAction::Other {
423 detail: format!("project_created:{name}:{source}"),
424 },
425 KernelEvent::ProjectActivated {
426 project_id: _,
427 name,
428 } => AuditAction::Other {
429 detail: format!("project_activated:{name}"),
430 },
431 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
433 detail: format!("tool_started:{tool_name}"),
434 },
435 KernelEvent::ToolExecutionFinished {
436 tool_name,
437 is_error,
438 ..
439 } => AuditAction::Other {
440 detail: format!(
441 "tool_finished:{tool_name}:{}",
442 if *is_error { "error" } else { "ok" }
443 ),
444 },
445 KernelEvent::ToolExecutionProgress {
446 tool_name,
447 tab_id,
448 context,
449 ..
450 } => AuditAction::Other {
451 detail: {
452 let mut d = format!("tool_progress:{tool_name}");
453 if let Some(id) = tab_id {
454 d.push_str(&format!(":tab={id}"));
455 }
456 if let Some(ctx) = context
457 .as_ref()
458 .and_then(|c| c.get("kind"))
459 .and_then(|k| k.as_str())
460 {
461 d.push_str(&format!(":{ctx}"));
462 }
463 d
464 },
465 },
466 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
467 entry_id: format!("recall:{query}:{count}results"),
468 },
469 KernelEvent::TokenUsageUpdate {
470 input_tokens,
471 output_tokens,
472 ..
473 } => AuditAction::Other {
474 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
475 },
476 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
477 detail: format!("reasoning:{source}"),
478 },
479 KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
480 detail: format!("compaction:triggered:{source}"),
481 },
482 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
483 detail: format!("calendar:created:{uid}:{title}"),
484 },
485 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
486 detail: format!("calendar:updated:{uid}:{title}"),
487 },
488 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
489 detail: format!("calendar:deleted:{uid}:{title}"),
490 },
491 KernelEvent::EmailSent {
492 subject,
493 message_id,
494 template_name,
495 } => AuditAction::Other {
496 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
497 },
498 KernelEvent::KnowledgePersisted {
499 session_id,
500 message_index,
501 path,
502 source,
503 } => AuditAction::Other {
504 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
505 },
506 KernelEvent::KnowledgeRemoved {
507 session_id,
508 message_index,
509 } => AuditAction::Other {
510 detail: format!("knowledge:removed:{session_id}:{message_index}"),
511 },
512 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
513 detail: format!("ask_user:{id}:{question}"),
514 },
515 KernelEvent::PersonaCreated {
516 id, name, source, ..
517 } => AuditAction::Other {
518 detail: format!("persona:created:{id}:{name}:{source}"),
519 },
520 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
521 detail: format!("persona:updated:{id}:{name}:{source}"),
522 },
523 KernelEvent::PhaseStarted { phase, .. } => AuditAction::Other {
524 detail: format!("phase_started:{phase}"),
525 },
526 KernelEvent::PhaseCompleted { phase, .. } => AuditAction::Other {
527 detail: format!("phase_completed:{phase}"),
528 },
529 }
530}
531
532fn extract_agent_id(event: &KernelEvent) -> String {
534 match event {
535 KernelEvent::AgentCreated { id, .. } => id.to_string(),
536 KernelEvent::AgentStarted { id, .. } => id.to_string(),
537 KernelEvent::AgentStopped { id, .. } => id.to_string(),
538 KernelEvent::AgentFailed { id, .. } => id.to_string(),
539 KernelEvent::MessageReceived { from, .. } => from.to_string(),
540 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
541 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
542 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
543 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
545 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
546 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
547 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
548 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
549 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
550 KernelEvent::PhaseStarted { session_id, .. } => format!("session:{session_id}"),
551 KernelEvent::PhaseCompleted { session_id, .. } => format!("session:{session_id}"),
552 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
553 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
554 KernelEvent::CompactionTriggered { session_id, .. } => session_id
555 .as_ref()
556 .map(|s| format!("session:{s}"))
557 .unwrap_or_else(|| "system".to_string()),
558 _ => "system".to_string(),
559 }
560}
561
562pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
568 let mut rx = bus.subscribe();
569 tokio::spawn(async move {
570 loop {
571 match rx.recv().await {
572 Ok(event) => {
573 let actor = extract_agent_id(&event);
574 let action = kernel_event_to_audit_action(&event);
575 let resource = format!("{event:?}");
576 audit.append(actor, action, resource);
577 }
578 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
579 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
583 tracing::warn!(
584 skipped = n,
585 "Audit trail subscriber lagged, skipping events"
586 );
587 continue;
588 }
589 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
590 tracing::info!("Audit trail event bus closed, exiting");
591 break;
592 }
593 }
594 }
595 });
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 fn sample_event(name: &str) -> KernelEvent {
603 KernelEvent::AgentCreated {
604 id: AgentId::new_v4(),
605 name: name.to_string(),
606 }
607 }
608
609 #[test]
610 fn test_event_bus_uses_sdk() {
611 let bus: EventBus = EventBus::new(256);
612 assert!(format!("{:?}", bus).contains("EventBus"));
613 }
614
615 #[tokio::test]
616 async fn test_publish_no_subscribers_ok() {
617 let bus = EventBus::new(16);
618 let result = bus.publish(sample_event("orphan"));
619 assert!(result.is_ok());
620 }
621
622 #[tokio::test]
623 async fn test_single_subscriber_receives_event() {
624 let bus = EventBus::new(16);
625 let mut rx = bus.subscribe();
626
627 let event = sample_event("test-agent");
628 bus.publish(event.clone()).unwrap();
629
630 let received = rx.try_recv().expect("should receive event");
631 match received {
632 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
633 _ => panic!("wrong event type"),
634 }
635 }
636
637 #[tokio::test]
638 async fn test_multiple_subscribers_receive_events() {
639 let bus = EventBus::new(16);
640 let mut rx1 = bus.subscribe();
641 let mut rx2 = bus.subscribe();
642
643 let event = sample_event("multi");
644 bus.publish(event.clone()).unwrap();
645
646 let r1 = rx1.try_recv().expect("rx1 should receive event");
647 let r2 = rx2.try_recv().expect("rx2 should receive event");
648
649 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
650 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
651 }
652
653 #[tokio::test]
654 async fn test_kernel_event_to_audit_action() {
655 let event = KernelEvent::AgentFailed {
656 id: AgentId::new_v4(),
657 error: "boom".to_string(),
658 };
659 let action = kernel_event_to_audit_action(&event);
660 match action {
661 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
662 other => panic!("expected AgentExit, got {other:?}"),
663 }
664 }
665
666 #[test]
672 fn test_rfc015_event_round_trip_json() {
673 let cases: Vec<KernelEvent> = vec![
674 KernelEvent::ToolExecutionStarted {
675 session_id: "s1".into(),
676 tool_name: "read_file".into(),
677 tool_call_id: "call_1".into(),
678 tool_args: serde_json::json!({"path": "/src/main.rs"}),
679 context: None,
680 },
681 KernelEvent::ToolExecutionFinished {
682 session_id: "s1".into(),
683 tool_call_id: "call_1".into(),
684 tool_name: "read_file".into(),
685 duration_ms: 234,
686 is_error: false,
687 output_summary: "fn main() {}".into(),
688 },
689 KernelEvent::ToolExecutionProgress {
690 session_id: "s1".into(),
691 tool_call_id: "call_1".into(),
692 tool_name: "read_file".into(),
693 progress: "reading line 42/100".into(),
694 tab_id: None,
695 context: None,
696 },
697 KernelEvent::MemoryRecallUsed {
698 session_id: "s1".into(),
699 query: "rust errors".into(),
700 count: 3,
701 source: "warm".into(),
702 },
703 KernelEvent::TokenUsageUpdate {
704 session_id: "s1".into(),
705 input_tokens: 1234,
706 output_tokens: 567,
707 },
708 KernelEvent::ReasoningFragment {
709 session_id: "s1".into(),
710 content: "compaction done".into(),
711 source: "compaction".into(),
712 },
713 ];
714 for event in cases {
715 let json = serde_json::to_string(&event).expect("serialize");
716 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
717 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
718 assert_eq!(json, json2, "round-trip should be stable");
719 }
720 }
721
722 #[test]
725 fn test_tool_execution_progress_serde_round_trip() {
726 let event = KernelEvent::ToolExecutionProgress {
727 session_id: "s-abc".into(),
728 tool_call_id: "call_42".into(),
729 tool_name: "browse".into(),
730 progress: "loading https://example.com".into(),
731 tab_id: Some(Uuid::new_v4()),
732 context: None,
733 };
734 let json = serde_json::to_string(&event).expect("serialize");
735 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
736 match back {
737 KernelEvent::ToolExecutionProgress {
738 ref session_id,
739 ref tool_call_id,
740 ref tool_name,
741 ref progress,
742 tab_id,
743 ..
744 } => {
745 assert_eq!(session_id, "s-abc");
746 assert_eq!(tool_call_id, "call_42");
747 assert_eq!(tool_name, "browse");
748 assert_eq!(progress, "loading https://example.com");
749 assert!(tab_id.is_some(), "tab_id should round-trip when present");
750 }
751 other => panic!("expected ToolExecutionProgress, got {other:?}"),
752 }
753 }
754
755 #[test]
761 fn test_tool_execution_progress_audit_action() {
762 let with_tab = KernelEvent::ToolExecutionProgress {
763 session_id: "s1".into(),
764 tool_call_id: "c1".into(),
765 tool_name: "browse".into(),
766 progress: "navigating".into(),
767 tab_id: Some(Uuid::new_v4()),
768 context: None,
769 };
770 match kernel_event_to_audit_action(&with_tab) {
771 AuditAction::Other { detail } => {
772 assert!(detail.contains("tool_progress"), "detail: {detail}");
773 assert!(detail.contains("browse"), "detail: {detail}");
774 assert!(
775 detail.contains(":tab="),
776 "detail should include tab id: {detail}"
777 );
778 }
779 other => panic!("expected Other, got {other:?}"),
780 }
781 let without_tab = KernelEvent::ToolExecutionProgress {
782 session_id: "s1".into(),
783 tool_call_id: "c1".into(),
784 tool_name: "browse".into(),
785 progress: "navigating".into(),
786 tab_id: None,
787 context: None,
788 };
789 match kernel_event_to_audit_action(&without_tab) {
790 AuditAction::Other { detail } => {
791 assert_eq!(detail, "tool_progress:browse");
792 }
793 other => panic!("expected Other, got {other:?}"),
794 }
795 }
796
797 #[test]
801 fn test_tool_execution_progress_tab_id_optional_in_serde() {
802 let legacy_json = r#"{
805 "ToolExecutionProgress": {
806 "session_id": "s-old",
807 "tool_call_id": "call_legacy",
808 "tool_name": "browse",
809 "progress": "step 1"
810 }
811 }"#;
812 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
813 match &event {
814 KernelEvent::ToolExecutionProgress {
815 session_id,
816 tool_call_id,
817 tool_name,
818 progress,
819 tab_id,
820 ..
821 } => {
822 assert_eq!(session_id, "s-old");
823 assert_eq!(tool_call_id, "call_legacy");
824 assert_eq!(tool_name, "browse");
825 assert_eq!(progress, "step 1");
826 assert!(tab_id.is_none(), "missing field should default to None");
827 }
828 other => panic!("expected ToolExecutionProgress, got {other:?}"),
829 }
830 let json = serde_json::to_string(&event).expect("serialize");
833 assert!(
834 !json.contains("tab_id"),
835 "tab_id should be omitted when None: {json}"
836 );
837 }
838
839 #[test]
843 fn test_rfc015_extract_agent_id() {
844 let event = KernelEvent::ToolExecutionStarted {
845 session_id: "abc-123".into(),
846 tool_name: "bash".into(),
847 tool_call_id: "c1".into(),
848 tool_args: serde_json::Value::Null,
849 context: None,
850 };
851 let action = kernel_event_to_audit_action(&event);
854 match action {
855 AuditAction::Other { detail } => {
856 assert!(
857 detail.contains("bash"),
858 "tool name in audit detail: {detail}"
859 );
860 }
861 other => panic!("expected Other, got {other:?}"),
862 }
863 }
864}