use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FlowExecutionEvent {
FlowStart {
flow_name: String,
backend: String,
timestamp_ms: u64,
},
StepStart {
step_name: String,
step_index: usize,
step_type: String,
timestamp_ms: u64,
},
StepToken {
step_name: String,
content: String,
token_index: u64,
timestamp_ms: u64,
},
StepComplete {
step_name: String,
step_index: usize,
success: bool,
full_output: String,
tokens_input: u64,
tokens_output: u64,
timestamp_ms: u64,
},
ToolCall {
step_name: String,
tool_name: String,
content: String,
timestamp_ms: u64,
},
FlowComplete {
flow_name: String,
backend: String,
success: bool,
steps_executed: usize,
tokens_input: u64,
tokens_output: u64,
latency_ms: u64,
timestamp_ms: u64,
},
FlowError {
flow_name: String,
error: String,
timestamp_ms: u64,
},
}
impl FlowExecutionEvent {
pub fn is_terminator(&self) -> bool {
matches!(
self,
FlowExecutionEvent::FlowComplete { .. } | FlowExecutionEvent::FlowError { .. }
)
}
pub fn is_step_scoped(&self) -> bool {
matches!(
self,
FlowExecutionEvent::StepStart { .. }
| FlowExecutionEvent::StepToken { .. }
| FlowExecutionEvent::StepComplete { .. }
| FlowExecutionEvent::ToolCall { .. }
)
}
pub fn kind(&self) -> &'static str {
match self {
FlowExecutionEvent::FlowStart { .. } => "flow_start",
FlowExecutionEvent::StepStart { .. } => "step_start",
FlowExecutionEvent::StepToken { .. } => "step_token",
FlowExecutionEvent::StepComplete { .. } => "step_complete",
FlowExecutionEvent::ToolCall { .. } => "tool_call",
FlowExecutionEvent::FlowComplete { .. } => "flow_complete",
FlowExecutionEvent::FlowError { .. } => "flow_error",
}
}
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn ev_flow_start() -> FlowExecutionEvent {
FlowExecutionEvent::FlowStart {
flow_name: "F".to_string(),
backend: "stub".to_string(),
timestamp_ms: 1_000_000,
}
}
fn ev_step_token() -> FlowExecutionEvent {
FlowExecutionEvent::StepToken {
step_name: "S".to_string(),
content: "hello".to_string(),
token_index: 1,
timestamp_ms: 1_000_001,
}
}
fn ev_flow_complete() -> FlowExecutionEvent {
FlowExecutionEvent::FlowComplete {
flow_name: "F".to_string(),
backend: "stub".to_string(),
success: true,
steps_executed: 1,
tokens_input: 0,
tokens_output: 1,
latency_ms: 50,
timestamp_ms: 1_000_010,
}
}
#[test]
fn flow_start_serializes_with_kind_discriminator() {
let s = serde_json::to_string(&ev_flow_start()).unwrap();
assert!(s.contains(r#""kind":"flow_start""#));
assert!(s.contains(r#""flow_name":"F""#));
assert!(s.contains(r#""backend":"stub""#));
assert!(s.contains(r#""timestamp_ms":1000000"#));
}
#[test]
fn step_token_serializes_with_token_index() {
let s = serde_json::to_string(&ev_step_token()).unwrap();
assert!(s.contains(r#""kind":"step_token""#));
assert!(s.contains(r#""token_index":1"#));
assert!(s.contains(r#""content":"hello""#));
}
#[test]
fn flow_complete_serializes_with_latency_ms() {
let s = serde_json::to_string(&ev_flow_complete()).unwrap();
assert!(s.contains(r#""kind":"flow_complete""#));
assert!(s.contains(r#""steps_executed":1"#));
assert!(s.contains(r#""latency_ms":50"#));
assert!(s.contains(r#""success":true"#));
}
#[test]
fn round_trip_through_json_preserves_every_variant() {
let cases = vec![
ev_flow_start(),
FlowExecutionEvent::StepStart {
step_name: "S".to_string(),
step_index: 0,
step_type: "step".to_string(),
timestamp_ms: 1,
},
ev_step_token(),
FlowExecutionEvent::StepComplete {
step_name: "S".to_string(),
step_index: 0,
success: true,
full_output: "hello world".to_string(),
tokens_input: 0,
tokens_output: 2,
timestamp_ms: 2,
},
ev_flow_complete(),
FlowExecutionEvent::FlowError {
flow_name: "F".to_string(),
error: "boom".to_string(),
timestamp_ms: 3,
},
];
for e in cases {
let s = serde_json::to_string(&e).unwrap();
let back: FlowExecutionEvent = serde_json::from_str(&s).unwrap();
assert_eq!(back, e, "round-trip MUST preserve every variant");
}
}
#[test]
fn is_terminator_predicate_is_total() {
assert!(!ev_flow_start().is_terminator());
assert!(!ev_step_token().is_terminator());
assert!(!FlowExecutionEvent::StepStart {
step_name: "S".to_string(),
step_index: 0,
step_type: "step".to_string(),
timestamp_ms: 0,
}
.is_terminator());
assert!(!FlowExecutionEvent::StepComplete {
step_name: "S".to_string(),
step_index: 0,
success: true,
full_output: "".to_string(),
tokens_input: 0,
tokens_output: 0,
timestamp_ms: 0,
}
.is_terminator());
assert!(ev_flow_complete().is_terminator());
assert!(FlowExecutionEvent::FlowError {
flow_name: "F".to_string(),
error: "x".to_string(),
timestamp_ms: 0,
}
.is_terminator());
}
#[test]
fn is_step_scoped_predicate_is_total() {
assert!(!ev_flow_start().is_step_scoped());
assert!(ev_step_token().is_step_scoped());
assert!(FlowExecutionEvent::StepStart {
step_name: "S".to_string(),
step_index: 0,
step_type: "step".to_string(),
timestamp_ms: 0,
}
.is_step_scoped());
assert!(FlowExecutionEvent::StepComplete {
step_name: "S".to_string(),
step_index: 0,
success: true,
full_output: "".to_string(),
tokens_input: 0,
tokens_output: 0,
timestamp_ms: 0,
}
.is_step_scoped());
assert!(!ev_flow_complete().is_step_scoped());
assert!(!FlowExecutionEvent::FlowError {
flow_name: "F".to_string(),
error: "x".to_string(),
timestamp_ms: 0,
}
.is_step_scoped());
}
#[test]
fn kind_strings_match_serde_rename() {
assert_eq!(ev_flow_start().kind(), "flow_start");
assert_eq!(ev_step_token().kind(), "step_token");
assert_eq!(ev_flow_complete().kind(), "flow_complete");
assert_eq!(
FlowExecutionEvent::StepStart {
step_name: "S".to_string(),
step_index: 0,
step_type: "".to_string(),
timestamp_ms: 0,
}
.kind(),
"step_start"
);
assert_eq!(
FlowExecutionEvent::StepComplete {
step_name: "S".to_string(),
step_index: 0,
success: true,
full_output: "".to_string(),
tokens_input: 0,
tokens_output: 0,
timestamp_ms: 0,
}
.kind(),
"step_complete"
);
assert_eq!(
FlowExecutionEvent::FlowError {
flow_name: "F".to_string(),
error: "x".to_string(),
timestamp_ms: 0,
}
.kind(),
"flow_error"
);
}
}