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 CalendarEventCreated {
242 uid: String,
244 title: String,
246 start: String,
248 end: String,
250 },
251 CalendarEventUpdated {
253 uid: String,
255 title: String,
257 },
258 CalendarEventDeleted {
260 uid: String,
262 title: String,
264 },
265 EmailSent {
267 subject: String,
269 message_id: String,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 template_name: Option<String>,
274 },
275
276 KnowledgePersisted {
279 session_id: String,
280 message_index: usize,
281 path: String,
282 source: String, },
284 KnowledgeRemoved {
286 session_id: String,
287 message_index: usize,
288 },
289 AskUserRequest {
293 id: String,
296 question: String,
298 options: Vec<String>,
300 },
301}
302
303pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
305 match event {
306 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
307 task_type: name.clone(),
308 },
309 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
310 task_type: "started".to_string(),
311 },
312 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
313 reason: if *success {
314 "completed".to_string()
315 } else {
316 "stopped".to_string()
317 },
318 },
319 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
320 reason: error.clone(),
321 },
322 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
323 detail: format!("message: {content}"),
324 },
325 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
326 detail: format!("agent_output:{output}"),
327 },
328 KernelEvent::ApprovalRequested {
329 id,
330 action,
331 resource,
332 ..
333 } => AuditAction::Other {
334 detail: format!("approval_requested:{id}:{action}:{resource}"),
335 },
336 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
337 detail: format!("approval_resolved:{id}:{approved}"),
338 },
339 KernelEvent::MemoryStored {
340 id, memory_type, ..
341 } => AuditAction::MemoryWrite {
342 entry_id: format!("{id}:{memory_type}"),
343 },
344 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
345 entry_id: format!("query:{query}:{count}results"),
346 },
347 KernelEvent::AgentGroupCreated {
348 group_id,
349 agent_count,
350 } => AuditAction::Other {
351 detail: format!("group_created:{group_id}:{agent_count}agents"),
352 },
353 KernelEvent::AgentGroupMemberCompleted {
354 group_id,
355 agent_id,
356 success,
357 } => AuditAction::Other {
358 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
359 },
360 KernelEvent::ProjectCreated {
361 project_id: _,
362 name,
363 source,
364 } => AuditAction::Other {
365 detail: format!("project_created:{name}:{source}"),
366 },
367 KernelEvent::ProjectActivated {
368 project_id: _,
369 name,
370 } => AuditAction::Other {
371 detail: format!("project_activated:{name}"),
372 },
373 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
375 detail: format!("tool_started:{tool_name}"),
376 },
377 KernelEvent::ToolExecutionFinished {
378 tool_name,
379 is_error,
380 ..
381 } => AuditAction::Other {
382 detail: format!(
383 "tool_finished:{tool_name}:{}",
384 if *is_error { "error" } else { "ok" }
385 ),
386 },
387 KernelEvent::ToolExecutionProgress {
388 tool_name,
389 tab_id,
390 context,
391 ..
392 } => AuditAction::Other {
393 detail: {
394 let mut d = format!("tool_progress:{tool_name}");
395 if let Some(id) = tab_id {
396 d.push_str(&format!(":tab={id}"));
397 }
398 if let Some(ctx) = context
399 .as_ref()
400 .and_then(|c| c.get("kind"))
401 .and_then(|k| k.as_str())
402 {
403 d.push_str(&format!(":{ctx}"));
404 }
405 d
406 },
407 },
408 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
409 entry_id: format!("recall:{query}:{count}results"),
410 },
411 KernelEvent::TokenUsageUpdate {
412 input_tokens,
413 output_tokens,
414 ..
415 } => AuditAction::Other {
416 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
417 },
418 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
419 detail: format!("reasoning:{source}"),
420 },
421 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
422 detail: format!("calendar:created:{uid}:{title}"),
423 },
424 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
425 detail: format!("calendar:updated:{uid}:{title}"),
426 },
427 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
428 detail: format!("calendar:deleted:{uid}:{title}"),
429 },
430 KernelEvent::EmailSent {
431 subject,
432 message_id,
433 template_name,
434 } => AuditAction::Other {
435 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
436 },
437 KernelEvent::KnowledgePersisted {
438 session_id,
439 message_index,
440 path,
441 source,
442 } => AuditAction::Other {
443 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
444 },
445 KernelEvent::KnowledgeRemoved {
446 session_id,
447 message_index,
448 } => AuditAction::Other {
449 detail: format!("knowledge:removed:{session_id}:{message_index}"),
450 },
451 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
452 detail: format!("ask_user:{id}:{question}"),
453 },
454 }
455}
456
457fn extract_agent_id(event: &KernelEvent) -> String {
459 match event {
460 KernelEvent::AgentCreated { id, .. } => id.to_string(),
461 KernelEvent::AgentStarted { id, .. } => id.to_string(),
462 KernelEvent::AgentStopped { id, .. } => id.to_string(),
463 KernelEvent::AgentFailed { id, .. } => id.to_string(),
464 KernelEvent::MessageReceived { from, .. } => from.to_string(),
465 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
466 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
467 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
468 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
470 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
471 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
472 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
473 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
474 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
475 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
476 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
477 _ => "system".to_string(),
478 }
479}
480
481pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
487 let mut rx = bus.subscribe();
488 tokio::spawn(async move {
489 loop {
490 match rx.recv().await {
491 Ok(event) => {
492 let actor = extract_agent_id(&event);
493 let action = kernel_event_to_audit_action(&event);
494 let resource = format!("{event:?}");
495 audit.append(actor, action, resource);
496 }
497 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
498 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
502 tracing::warn!(
503 skipped = n,
504 "Audit trail subscriber lagged, skipping events"
505 );
506 continue;
507 }
508 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
509 tracing::info!("Audit trail event bus closed, exiting");
510 break;
511 }
512 }
513 }
514 });
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn sample_event(name: &str) -> KernelEvent {
522 KernelEvent::AgentCreated {
523 id: AgentId::new_v4(),
524 name: name.to_string(),
525 }
526 }
527
528 #[test]
529 fn test_event_bus_uses_sdk() {
530 let bus: EventBus = EventBus::new(256);
531 assert!(format!("{:?}", bus).contains("EventBus"));
532 }
533
534 #[tokio::test]
535 async fn test_publish_no_subscribers_ok() {
536 let bus = EventBus::new(16);
537 let result = bus.publish(sample_event("orphan"));
538 assert!(result.is_ok());
539 }
540
541 #[tokio::test]
542 async fn test_single_subscriber_receives_event() {
543 let bus = EventBus::new(16);
544 let mut rx = bus.subscribe();
545
546 let event = sample_event("test-agent");
547 bus.publish(event.clone()).unwrap();
548
549 let received = rx.try_recv().expect("should receive event");
550 match received {
551 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
552 _ => panic!("wrong event type"),
553 }
554 }
555
556 #[tokio::test]
557 async fn test_multiple_subscribers_receive_events() {
558 let bus = EventBus::new(16);
559 let mut rx1 = bus.subscribe();
560 let mut rx2 = bus.subscribe();
561
562 let event = sample_event("multi");
563 bus.publish(event.clone()).unwrap();
564
565 let r1 = rx1.try_recv().expect("rx1 should receive event");
566 let r2 = rx2.try_recv().expect("rx2 should receive event");
567
568 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
569 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
570 }
571
572 #[tokio::test]
573 async fn test_kernel_event_to_audit_action() {
574 let event = KernelEvent::AgentFailed {
575 id: AgentId::new_v4(),
576 error: "boom".to_string(),
577 };
578 let action = kernel_event_to_audit_action(&event);
579 match action {
580 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
581 other => panic!("expected AgentExit, got {other:?}"),
582 }
583 }
584
585 #[test]
591 fn test_rfc015_event_round_trip_json() {
592 let cases: Vec<KernelEvent> = vec![
593 KernelEvent::ToolExecutionStarted {
594 session_id: "s1".into(),
595 tool_name: "read_file".into(),
596 tool_call_id: "call_1".into(),
597 tool_args: serde_json::json!({"path": "/src/main.rs"}),
598 context: None,
599 },
600 KernelEvent::ToolExecutionFinished {
601 session_id: "s1".into(),
602 tool_call_id: "call_1".into(),
603 tool_name: "read_file".into(),
604 duration_ms: 234,
605 is_error: false,
606 output_summary: "fn main() {}".into(),
607 },
608 KernelEvent::ToolExecutionProgress {
609 session_id: "s1".into(),
610 tool_call_id: "call_1".into(),
611 tool_name: "read_file".into(),
612 progress: "reading line 42/100".into(),
613 tab_id: None,
614 context: None,
615 },
616 KernelEvent::MemoryRecallUsed {
617 session_id: "s1".into(),
618 query: "rust errors".into(),
619 count: 3,
620 source: "warm".into(),
621 },
622 KernelEvent::TokenUsageUpdate {
623 session_id: "s1".into(),
624 input_tokens: 1234,
625 output_tokens: 567,
626 },
627 KernelEvent::ReasoningFragment {
628 session_id: "s1".into(),
629 content: "compaction done".into(),
630 source: "compaction".into(),
631 },
632 ];
633 for event in cases {
634 let json = serde_json::to_string(&event).expect("serialize");
635 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
636 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
637 assert_eq!(json, json2, "round-trip should be stable");
638 }
639 }
640
641 #[test]
644 fn test_tool_execution_progress_serde_round_trip() {
645 let event = KernelEvent::ToolExecutionProgress {
646 session_id: "s-abc".into(),
647 tool_call_id: "call_42".into(),
648 tool_name: "browse".into(),
649 progress: "loading https://example.com".into(),
650 tab_id: Some(Uuid::new_v4()),
651 context: None,
652 };
653 let json = serde_json::to_string(&event).expect("serialize");
654 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
655 match back {
656 KernelEvent::ToolExecutionProgress {
657 ref session_id,
658 ref tool_call_id,
659 ref tool_name,
660 ref progress,
661 tab_id,
662 ..
663 } => {
664 assert_eq!(session_id, "s-abc");
665 assert_eq!(tool_call_id, "call_42");
666 assert_eq!(tool_name, "browse");
667 assert_eq!(progress, "loading https://example.com");
668 assert!(tab_id.is_some(), "tab_id should round-trip when present");
669 }
670 other => panic!("expected ToolExecutionProgress, got {other:?}"),
671 }
672 }
673
674 #[test]
680 fn test_tool_execution_progress_audit_action() {
681 let with_tab = KernelEvent::ToolExecutionProgress {
682 session_id: "s1".into(),
683 tool_call_id: "c1".into(),
684 tool_name: "browse".into(),
685 progress: "navigating".into(),
686 tab_id: Some(Uuid::new_v4()),
687 context: None,
688 };
689 match kernel_event_to_audit_action(&with_tab) {
690 AuditAction::Other { detail } => {
691 assert!(detail.contains("tool_progress"), "detail: {detail}");
692 assert!(detail.contains("browse"), "detail: {detail}");
693 assert!(
694 detail.contains(":tab="),
695 "detail should include tab id: {detail}"
696 );
697 }
698 other => panic!("expected Other, got {other:?}"),
699 }
700 let without_tab = KernelEvent::ToolExecutionProgress {
701 session_id: "s1".into(),
702 tool_call_id: "c1".into(),
703 tool_name: "browse".into(),
704 progress: "navigating".into(),
705 tab_id: None,
706 context: None,
707 };
708 match kernel_event_to_audit_action(&without_tab) {
709 AuditAction::Other { detail } => {
710 assert_eq!(detail, "tool_progress:browse");
711 }
712 other => panic!("expected Other, got {other:?}"),
713 }
714 }
715
716 #[test]
720 fn test_tool_execution_progress_tab_id_optional_in_serde() {
721 let legacy_json = r#"{
724 "ToolExecutionProgress": {
725 "session_id": "s-old",
726 "tool_call_id": "call_legacy",
727 "tool_name": "browse",
728 "progress": "step 1"
729 }
730 }"#;
731 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
732 match &event {
733 KernelEvent::ToolExecutionProgress {
734 session_id,
735 tool_call_id,
736 tool_name,
737 progress,
738 tab_id,
739 ..
740 } => {
741 assert_eq!(session_id, "s-old");
742 assert_eq!(tool_call_id, "call_legacy");
743 assert_eq!(tool_name, "browse");
744 assert_eq!(progress, "step 1");
745 assert!(tab_id.is_none(), "missing field should default to None");
746 }
747 other => panic!("expected ToolExecutionProgress, got {other:?}"),
748 }
749 let json = serde_json::to_string(&event).expect("serialize");
752 assert!(
753 !json.contains("tab_id"),
754 "tab_id should be omitted when None: {json}"
755 );
756 }
757
758 #[test]
762 fn test_rfc015_extract_agent_id() {
763 let event = KernelEvent::ToolExecutionStarted {
764 session_id: "abc-123".into(),
765 tool_name: "bash".into(),
766 tool_call_id: "c1".into(),
767 tool_args: serde_json::Value::Null,
768 context: None,
769 };
770 let action = kernel_event_to_audit_action(&event);
773 match action {
774 AuditAction::Other { detail } => {
775 assert!(
776 detail.contains("bash"),
777 "tool name in audit detail: {detail}"
778 );
779 }
780 other => panic!("expected Other, got {other:?}"),
781 }
782 }
783}