use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::{FsmState, StepKind, StepStatus};
fn default_attempt() -> u32 {
1
}
pub fn step_trace_id(run_id: Uuid, name: &str, position: u32) -> Uuid {
Uuid::new_v5(
&Uuid::NAMESPACE_OID,
format!("{run_id}:{name}:{position}").as_bytes(),
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Step {
pub id: Uuid,
pub trace_id: Uuid,
pub run_id: Uuid,
pub name: String,
pub kind: StepKind,
pub position: u32,
pub status: FsmState<StepStatus>,
#[serde(default = "default_attempt")]
pub attempt: u32,
pub input: Option<Value>,
pub output: Option<Value>,
pub error: Option<String>,
pub duration_ms: u64,
pub cost_usd: Decimal,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub debug_messages: Option<Value>,
#[serde(default)]
pub is_error_handler: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewStep {
pub run_id: Uuid,
pub trace_id: Uuid,
pub name: String,
pub kind: StepKind,
pub position: u32,
pub input: Option<Value>,
#[serde(default)]
pub is_error_handler: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StepUpdate {
pub status: Option<StepStatus>,
pub output: Option<Value>,
pub error: Option<String>,
pub duration_ms: Option<u64>,
pub cost_usd: Option<Decimal>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub debug_messages: Option<Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn newstep_serde_roundtrip() {
let new_step = NewStep {
run_id: Uuid::nil(),
trace_id: step_trace_id(Uuid::nil(), "build", 0),
name: "build".to_string(),
kind: StepKind::Shell,
position: 0,
input: Some(json!({"command": "cargo build"})),
is_error_handler: false,
};
let json = serde_json::to_string(&new_step).expect("serialize");
let back: NewStep = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.run_id, new_step.run_id);
assert_eq!(back.name, new_step.name);
assert_eq!(back.kind, new_step.kind);
assert_eq!(back.position, new_step.position);
assert_eq!(back.input, new_step.input);
}
#[test]
fn step_serde_preserves_all_fields() {
use crate::entities::FsmState;
use chrono::Utc;
let now = Utc::now();
let run_id = Uuid::now_v7();
let step = Step {
id: Uuid::now_v7(),
trace_id: step_trace_id(run_id, "test-step", 1),
run_id,
name: "test-step".to_string(),
kind: StepKind::Agent,
position: 1,
status: FsmState::new(StepStatus::Completed, Uuid::now_v7()),
attempt: 2,
input: Some(json!({"input": "data"})),
output: Some(json!({"output": "result"})),
error: None,
duration_ms: 2500,
cost_usd: Decimal::new(150, 2),
input_tokens: Some(100),
output_tokens: Some(200),
created_at: now,
updated_at: now,
started_at: Some(now),
completed_at: Some(now),
debug_messages: None,
is_error_handler: false,
};
let json = serde_json::to_string(&step).expect("serialize");
let back: Step = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, step.id);
assert_eq!(back.run_id, step.run_id);
assert_eq!(back.name, step.name);
assert_eq!(back.kind, step.kind);
assert_eq!(back.position, step.position);
assert_eq!(back.status.state, step.status.state);
assert_eq!(back.attempt, step.attempt);
assert_eq!(back.input, step.input);
assert_eq!(back.output, step.output);
assert_eq!(back.error, step.error);
assert_eq!(back.duration_ms, step.duration_ms);
assert_eq!(back.cost_usd, step.cost_usd);
assert_eq!(back.input_tokens, step.input_tokens);
assert_eq!(back.output_tokens, step.output_tokens);
}
#[test]
fn stepupdate_default_is_no_changes() {
let update = StepUpdate::default();
assert!(update.status.is_none());
assert!(update.output.is_none());
assert!(update.error.is_none());
assert!(update.duration_ms.is_none());
assert!(update.cost_usd.is_none());
assert!(update.input_tokens.is_none());
assert!(update.output_tokens.is_none());
assert!(update.started_at.is_none());
assert!(update.completed_at.is_none());
assert!(update.debug_messages.is_none());
}
#[test]
fn stepupdate_serde_roundtrip() {
let update = StepUpdate {
status: Some(StepStatus::Completed),
output: Some(json!({"result": "ok"})),
error: None,
duration_ms: Some(1000),
cost_usd: Some(Decimal::new(50, 2)),
input_tokens: Some(50),
output_tokens: Some(75),
started_at: None,
completed_at: None,
debug_messages: None,
};
let json = serde_json::to_string(&update).expect("serialize");
let back: StepUpdate = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.status, update.status);
assert_eq!(back.output, update.output);
assert_eq!(back.duration_ms, update.duration_ms);
assert_eq!(back.cost_usd, update.cost_usd);
assert_eq!(back.input_tokens, update.input_tokens);
assert_eq!(back.output_tokens, update.output_tokens);
}
#[test]
fn trace_id_is_deterministic() {
let run_id = Uuid::nil();
let id1 = step_trace_id(run_id, "build", 0);
let id2 = step_trace_id(run_id, "build", 0);
assert_eq!(id1, id2);
}
#[test]
fn trace_id_differs_for_different_inputs() {
let run_id = Uuid::nil();
let a = step_trace_id(run_id, "build", 0);
let b = step_trace_id(run_id, "test", 0);
let c = step_trace_id(run_id, "build", 1);
let d = step_trace_id(Uuid::max(), "build", 0);
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(a, d);
}
#[test]
fn trace_id_is_uuid_v5() {
let id = step_trace_id(Uuid::nil(), "build", 0);
assert_eq!(id.get_version_num(), 5);
}
}