use chrono::{DateTime, Utc};
use ironflow_store::models::{Step, StepKind, StepStatus};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::ArtifactResponse;
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Serialize, Deserialize)]
pub struct StepResponse {
pub id: Uuid,
pub trace_id: Uuid,
pub run_id: Uuid,
pub name: String,
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub kind: StepKind,
pub position: u32,
pub status: StepStatus,
pub attempt: u32,
#[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
pub input: Option<Value>,
#[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
pub output: Option<Value>,
pub error: Option<String>,
pub duration_ms: u64,
#[cfg_attr(feature = "openapi", schema(value_type = f64))]
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 dependencies: Vec<Uuid>,
#[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
pub debug_messages: Option<Value>,
#[serde(default)]
pub artifacts: Vec<ArtifactResponse>,
}
impl StepResponse {
pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
}
pub fn with_dependencies_and_artifacts(
step: Step,
dependencies: Vec<Uuid>,
artifacts: Vec<ArtifactResponse>,
) -> Self {
StepResponse {
id: step.id,
trace_id: step.trace_id,
run_id: step.run_id,
name: step.name,
kind: step.kind,
position: step.position,
status: step.status.state,
attempt: step.attempt,
input: step.input,
output: step.output,
error: step.error,
duration_ms: step.duration_ms,
cost_usd: step.cost_usd,
input_tokens: step.input_tokens,
output_tokens: step.output_tokens,
created_at: step.created_at,
updated_at: step.updated_at,
started_at: step.started_at,
completed_at: step.completed_at,
dependencies,
debug_messages: step.debug_messages,
artifacts,
}
}
}
impl From<Step> for StepResponse {
fn from(step: Step) -> Self {
Self::with_dependencies(step, Vec::new())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::{NewRun, NewStep, TriggerKind, step_trace_id};
use ironflow_store::store::RunStore;
use serde_json::json;
use super::*;
async fn step() -> Step {
let store = InMemoryStore::new();
let run = store
.create_run(NewRun {
created_by: None,
workflow_name: "test".to_string(),
trigger: TriggerKind::Manual,
payload: json!({}),
max_retries: 0,
handler_version: None,
labels: HashMap::new(),
scheduled_at: None,
idempotency_key: None,
max_cost_usd: None,
})
.await
.expect("create run")
.into_run();
store
.create_step(NewStep {
run_id: run.id,
trace_id: step_trace_id(run.id, "build", 0),
name: "build".to_string(),
kind: StepKind::Shell,
position: 0,
input: None,
is_error_handler: false,
})
.await
.expect("create step")
}
#[tokio::test]
async fn a_step_without_artifacts_exposes_an_empty_list() {
let response = StepResponse::from(step().await);
assert!(response.artifacts.is_empty());
}
#[tokio::test]
async fn artifacts_are_carried_through() {
let step = step().await;
let artifact = ArtifactResponse {
id: Uuid::now_v7(),
step_id: step.id,
name: "report.html".to_string(),
content_type: "text/html".to_string(),
size_bytes: 1,
sha256: "0".repeat(64),
created_at: Utc::now(),
};
let response =
StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
assert_eq!(response.artifacts.len(), 1);
assert_eq!(response.artifacts[0].name, "report.html");
}
#[tokio::test]
async fn artifacts_serialize_as_a_json_array() {
let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
assert!(body["artifacts"].is_array());
}
#[tokio::test]
async fn trace_id_is_exposed_in_step_response() {
let s = step().await;
let expected_trace_id = s.trace_id;
let response = StepResponse::from(s);
assert_eq!(response.trace_id, expected_trace_id);
let body = serde_json::to_value(&response).expect("serialize");
assert!(body["trace_id"].is_string());
}
}