1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use uuid::Uuid;
8
9use super::{Assignee, FsmState, StepKind, StepStatus};
10
11fn default_attempt() -> u32 {
14 1
15}
16
17pub fn step_trace_id(run_id: Uuid, name: &str, position: u32) -> Uuid {
38 Uuid::new_v5(
39 &Uuid::NAMESPACE_OID,
40 format!("{run_id}:{name}:{position}").as_bytes(),
41 )
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
56#[non_exhaustive]
57pub struct Step {
58 pub id: Uuid,
60 pub trace_id: Uuid,
65 pub run_id: Uuid,
67 pub name: String,
69 pub kind: StepKind,
71 pub position: u32,
78 pub status: FsmState<StepStatus>,
80 #[serde(default = "default_attempt")]
86 pub attempt: u32,
87 pub input: Option<Value>,
89 pub output: Option<Value>,
91 pub error: Option<String>,
93 pub duration_ms: u64,
95 pub cost_usd: Decimal,
97 pub input_tokens: Option<u64>,
99 pub output_tokens: Option<u64>,
101 pub created_at: DateTime<Utc>,
103 pub updated_at: DateTime<Utc>,
105 pub started_at: Option<DateTime<Utc>>,
107 pub completed_at: Option<DateTime<Utc>>,
109 pub debug_messages: Option<Value>,
111 #[serde(default)]
113 pub is_error_handler: bool,
114 #[serde(default)]
119 pub approval_deadline_at: Option<DateTime<Utc>>,
120 #[serde(default)]
122 pub approval_stage: u32,
123 #[serde(default)]
125 pub approval_assignee: Option<Assignee>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct NewStep {
150 pub run_id: Uuid,
152 pub trace_id: Uuid,
154 pub name: String,
156 pub kind: StepKind,
158 pub position: u32,
160 pub input: Option<Value>,
162 #[serde(default)]
164 pub is_error_handler: bool,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct StepUpdate {
185 pub status: Option<StepStatus>,
187 pub output: Option<Value>,
189 pub error: Option<String>,
191 pub duration_ms: Option<u64>,
193 pub cost_usd: Option<Decimal>,
195 pub input_tokens: Option<u64>,
197 pub output_tokens: Option<u64>,
199 pub started_at: Option<DateTime<Utc>>,
201 pub completed_at: Option<DateTime<Utc>>,
203 pub debug_messages: Option<Value>,
205 #[serde(default)]
208 pub approval_deadline_at: Option<DateTime<Utc>>,
209 #[serde(default)]
211 pub approval_stage: Option<u32>,
212 #[serde(default)]
214 pub approval_assignee: Option<Assignee>,
215 #[serde(default)]
218 pub clear_approval_deadline: bool,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use serde_json::json;
225
226 #[test]
227 fn newstep_serde_roundtrip() {
228 let new_step = NewStep {
229 run_id: Uuid::nil(),
230 trace_id: step_trace_id(Uuid::nil(), "build", 0),
231 name: "build".to_string(),
232 kind: StepKind::Shell,
233 position: 0,
234 input: Some(json!({"command": "cargo build"})),
235 is_error_handler: false,
236 };
237
238 let json = serde_json::to_string(&new_step).expect("serialize");
239 let back: NewStep = serde_json::from_str(&json).expect("deserialize");
240
241 assert_eq!(back.run_id, new_step.run_id);
242 assert_eq!(back.name, new_step.name);
243 assert_eq!(back.kind, new_step.kind);
244 assert_eq!(back.position, new_step.position);
245 assert_eq!(back.input, new_step.input);
246 }
247
248 #[test]
249 fn step_serde_preserves_all_fields() {
250 use crate::entities::FsmState;
251 use chrono::Utc;
252
253 let now = Utc::now();
254 let run_id = Uuid::now_v7();
255 let step = Step {
256 id: Uuid::now_v7(),
257 trace_id: step_trace_id(run_id, "test-step", 1),
258 run_id,
259 name: "test-step".to_string(),
260 kind: StepKind::Agent,
261 position: 1,
262 status: FsmState::new(StepStatus::Completed, Uuid::now_v7()),
263 attempt: 2,
264 input: Some(json!({"input": "data"})),
265 output: Some(json!({"output": "result"})),
266 error: None,
267 duration_ms: 2500,
268 cost_usd: Decimal::new(150, 2),
269 input_tokens: Some(100),
270 output_tokens: Some(200),
271 created_at: now,
272 updated_at: now,
273 started_at: Some(now),
274 completed_at: Some(now),
275 debug_messages: None,
276 is_error_handler: false,
277 approval_deadline_at: Some(now),
278 approval_stage: 2,
279 approval_assignee: Some(Assignee::group("sre-oncall")),
280 };
281
282 let json = serde_json::to_string(&step).expect("serialize");
283 let back: Step = serde_json::from_str(&json).expect("deserialize");
284
285 assert_eq!(back.id, step.id);
286 assert_eq!(back.run_id, step.run_id);
287 assert_eq!(back.name, step.name);
288 assert_eq!(back.kind, step.kind);
289 assert_eq!(back.position, step.position);
290 assert_eq!(back.status.state, step.status.state);
291 assert_eq!(back.attempt, step.attempt);
292 assert_eq!(back.input, step.input);
293 assert_eq!(back.output, step.output);
294 assert_eq!(back.error, step.error);
295 assert_eq!(back.duration_ms, step.duration_ms);
296 assert_eq!(back.cost_usd, step.cost_usd);
297 assert_eq!(back.input_tokens, step.input_tokens);
298 assert_eq!(back.output_tokens, step.output_tokens);
299 assert_eq!(back.approval_deadline_at, step.approval_deadline_at);
300 assert_eq!(back.approval_stage, step.approval_stage);
301 assert_eq!(back.approval_assignee, step.approval_assignee);
302 }
303
304 #[test]
305 fn step_serde_defaults_approval_fields_when_absent() {
306 let run_id = Uuid::now_v7();
307 let payload = json!({
308 "id": Uuid::now_v7(),
309 "trace_id": step_trace_id(run_id, "legacy", 0),
310 "run_id": run_id,
311 "name": "legacy",
312 "kind": "shell",
313 "position": 0,
314 "status": {"state": "pending", "state_machine_id": Uuid::now_v7()},
315 "input": null,
316 "output": null,
317 "error": null,
318 "duration_ms": 0,
319 "cost_usd": 0.0,
320 "input_tokens": null,
321 "output_tokens": null,
322 "created_at": "2026-09-21T12:00:00Z",
323 "updated_at": "2026-09-21T12:00:00Z",
324 "started_at": null,
325 "completed_at": null,
326 "debug_messages": null
327 });
328
329 let step: Step = serde_json::from_value(payload).expect("deserialize");
330
331 assert_eq!(step.approval_stage, 0);
332 assert!(step.approval_deadline_at.is_none());
333 assert!(step.approval_assignee.is_none());
334 }
335
336 #[test]
337 fn stepupdate_default_is_no_changes() {
338 let update = StepUpdate::default();
339 assert!(update.status.is_none());
340 assert!(update.output.is_none());
341 assert!(update.error.is_none());
342 assert!(update.duration_ms.is_none());
343 assert!(update.cost_usd.is_none());
344 assert!(update.input_tokens.is_none());
345 assert!(update.output_tokens.is_none());
346 assert!(update.started_at.is_none());
347 assert!(update.completed_at.is_none());
348 assert!(update.debug_messages.is_none());
349 assert!(update.approval_deadline_at.is_none());
350 assert!(update.approval_stage.is_none());
351 assert!(update.approval_assignee.is_none());
352 assert!(!update.clear_approval_deadline);
353 }
354
355 #[test]
356 fn stepupdate_serde_roundtrip() {
357 let update = StepUpdate {
358 status: Some(StepStatus::Completed),
359 output: Some(json!({"result": "ok"})),
360 error: None,
361 duration_ms: Some(1000),
362 cost_usd: Some(Decimal::new(50, 2)),
363 input_tokens: Some(50),
364 output_tokens: Some(75),
365 started_at: None,
366 completed_at: None,
367 debug_messages: None,
368 approval_deadline_at: Some(Utc::now()),
369 approval_stage: Some(1),
370 approval_assignee: Some(Assignee::group("sre-oncall")),
371 clear_approval_deadline: false,
372 };
373
374 let json = serde_json::to_string(&update).expect("serialize");
375 let back: StepUpdate = serde_json::from_str(&json).expect("deserialize");
376
377 assert_eq!(back.status, update.status);
378 assert_eq!(back.output, update.output);
379 assert_eq!(back.duration_ms, update.duration_ms);
380 assert_eq!(back.cost_usd, update.cost_usd);
381 assert_eq!(back.input_tokens, update.input_tokens);
382 assert_eq!(back.output_tokens, update.output_tokens);
383 assert_eq!(back.approval_deadline_at, update.approval_deadline_at);
384 assert_eq!(back.approval_stage, update.approval_stage);
385 assert_eq!(back.approval_assignee, update.approval_assignee);
386 assert_eq!(back.clear_approval_deadline, update.clear_approval_deadline);
387 }
388
389 #[test]
390 fn trace_id_is_deterministic() {
391 let run_id = Uuid::nil();
392 let id1 = step_trace_id(run_id, "build", 0);
393 let id2 = step_trace_id(run_id, "build", 0);
394 assert_eq!(id1, id2);
395 }
396
397 #[test]
398 fn trace_id_differs_for_different_inputs() {
399 let run_id = Uuid::nil();
400 let a = step_trace_id(run_id, "build", 0);
401 let b = step_trace_id(run_id, "test", 0);
402 let c = step_trace_id(run_id, "build", 1);
403 let d = step_trace_id(Uuid::max(), "build", 0);
404
405 assert_ne!(a, b);
406 assert_ne!(a, c);
407 assert_ne!(a, d);
408 }
409
410 #[test]
411 fn trace_id_is_uuid_v5() {
412 let id = step_trace_id(Uuid::nil(), "build", 0);
413 assert_eq!(id.get_version_num(), 5);
414 }
415}