1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use uuid::Uuid;
8
9use super::{ApprovalRequirement, Assignee, FsmState, StepApproval, 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")]
88 pub attempt: u32,
89 pub input: Option<Value>,
91 pub output: Option<Value>,
93 pub error: Option<String>,
95 pub duration_ms: u64,
97 pub cost_usd: Decimal,
99 pub input_tokens: Option<u64>,
101 #[serde(default)]
103 pub cache_read_input_tokens: Option<u64>,
104 #[serde(default)]
106 pub cache_creation_input_tokens: Option<u64>,
107 pub output_tokens: Option<u64>,
109 pub created_at: DateTime<Utc>,
111 pub updated_at: DateTime<Utc>,
113 pub started_at: Option<DateTime<Utc>>,
115 pub completed_at: Option<DateTime<Utc>>,
117 pub debug_messages: Option<Value>,
119 #[serde(default)]
121 pub is_error_handler: bool,
122 #[serde(default)]
127 pub approval_deadline_at: Option<DateTime<Utc>>,
128 #[serde(default)]
130 pub approval_stage: u32,
131 #[serde(default)]
133 pub approval_assignee: Option<Assignee>,
134 #[serde(default)]
140 pub approval_assignee_user_id: Option<Uuid>,
141 #[serde(default)]
144 pub approval_requirement: Option<ApprovalRequirement>,
145 #[serde(default)]
147 pub approvals: Vec<StepApproval>,
148 #[serde(default)]
150 pub account_id: Option<Uuid>,
151 #[serde(default)]
154 pub environment_id: Option<String>,
155 #[serde(default)]
158 pub session_id: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct NewStep {
183 pub run_id: Uuid,
185 pub trace_id: Uuid,
187 pub name: String,
189 pub kind: StepKind,
191 pub position: u32,
193 pub input: Option<Value>,
195 #[serde(default)]
197 pub is_error_handler: bool,
198}
199
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct StepUpdate {
218 pub status: Option<StepStatus>,
220 pub output: Option<Value>,
222 pub error: Option<String>,
224 pub duration_ms: Option<u64>,
226 pub cost_usd: Option<Decimal>,
228 pub input_tokens: Option<u64>,
230 #[serde(default)]
232 pub cache_read_input_tokens: Option<u64>,
233 #[serde(default)]
235 pub cache_creation_input_tokens: Option<u64>,
236 pub output_tokens: Option<u64>,
238 pub started_at: Option<DateTime<Utc>>,
240 pub completed_at: Option<DateTime<Utc>>,
242 pub debug_messages: Option<Value>,
244 #[serde(default)]
247 pub approval_deadline_at: Option<DateTime<Utc>>,
248 #[serde(default)]
250 pub approval_stage: Option<u32>,
251 #[serde(default)]
253 pub approval_assignee: Option<Assignee>,
254 #[serde(default)]
256 pub approval_requirement: Option<ApprovalRequirement>,
257 #[serde(default)]
260 pub clear_approval_deadline: bool,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub account_id: Option<Uuid>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub environment_id: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub session_id: Option<String>,
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use serde_json::json;
276
277 #[test]
278 fn newstep_serde_roundtrip() {
279 let new_step = NewStep {
280 run_id: Uuid::nil(),
281 trace_id: step_trace_id(Uuid::nil(), "build", 0),
282 name: "build".to_string(),
283 kind: StepKind::Shell,
284 position: 0,
285 input: Some(json!({"command": "cargo build"})),
286 is_error_handler: false,
287 };
288
289 let json = serde_json::to_string(&new_step).expect("serialize");
290 let back: NewStep = serde_json::from_str(&json).expect("deserialize");
291
292 assert_eq!(back.run_id, new_step.run_id);
293 assert_eq!(back.name, new_step.name);
294 assert_eq!(back.kind, new_step.kind);
295 assert_eq!(back.position, new_step.position);
296 assert_eq!(back.input, new_step.input);
297 }
298
299 #[test]
300 fn step_serde_preserves_all_fields() {
301 use crate::entities::FsmState;
302 use chrono::Utc;
303
304 let now = Utc::now();
305 let run_id = Uuid::now_v7();
306 let step = Step {
307 id: Uuid::now_v7(),
308 trace_id: step_trace_id(run_id, "test-step", 1),
309 run_id,
310 name: "test-step".to_string(),
311 kind: StepKind::Agent,
312 position: 1,
313 status: FsmState::new(StepStatus::Completed, Uuid::now_v7()),
314 attempt: 2,
315 input: Some(json!({"input": "data"})),
316 output: Some(json!({"output": "result"})),
317 error: None,
318 duration_ms: 2500,
319 cost_usd: Decimal::new(150, 2),
320 input_tokens: Some(100),
321 cache_read_input_tokens: Some(4000),
322 cache_creation_input_tokens: Some(300),
323 output_tokens: Some(200),
324 created_at: now,
325 updated_at: now,
326 started_at: Some(now),
327 completed_at: Some(now),
328 debug_messages: None,
329 is_error_handler: false,
330 approval_deadline_at: Some(now),
331 approval_stage: 2,
332 approval_assignee: Some(Assignee::group("sre-oncall")),
333 approval_assignee_user_id: Some(Uuid::now_v7()),
334 approval_requirement: Some(ApprovalRequirement {
335 reason: Some("amount > 10k".to_string()),
336 required_approvers: 2,
337 approver_groups: vec!["finance".to_string()],
338 }),
339 approvals: vec![StepApproval {
340 user_id: Uuid::now_v7(),
341 approved_by: "alice".to_string(),
342 at: now,
343 }],
344 account_id: Some(Uuid::now_v7()),
345 environment_id: Some("ironflow-env-0a1b2c".to_string()),
346 session_id: Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string()),
347 };
348
349 let json = serde_json::to_string(&step).expect("serialize");
350 let back: Step = serde_json::from_str(&json).expect("deserialize");
351
352 assert_eq!(back.id, step.id);
353 assert_eq!(back.run_id, step.run_id);
354 assert_eq!(back.name, step.name);
355 assert_eq!(back.kind, step.kind);
356 assert_eq!(back.position, step.position);
357 assert_eq!(back.status.state, step.status.state);
358 assert_eq!(back.attempt, step.attempt);
359 assert_eq!(back.input, step.input);
360 assert_eq!(back.output, step.output);
361 assert_eq!(back.error, step.error);
362 assert_eq!(back.duration_ms, step.duration_ms);
363 assert_eq!(back.cost_usd, step.cost_usd);
364 assert_eq!(back.input_tokens, step.input_tokens);
365 assert_eq!(back.cache_read_input_tokens, step.cache_read_input_tokens);
366 assert_eq!(
367 back.cache_creation_input_tokens,
368 step.cache_creation_input_tokens
369 );
370 assert_eq!(back.output_tokens, step.output_tokens);
371 assert_eq!(back.account_id, step.account_id);
372 assert_eq!(back.environment_id, step.environment_id);
373 assert_eq!(back.session_id, step.session_id);
374 assert_eq!(back.approval_deadline_at, step.approval_deadline_at);
375 assert_eq!(back.approval_stage, step.approval_stage);
376 assert_eq!(back.approval_assignee, step.approval_assignee);
377 assert_eq!(
378 back.approval_assignee_user_id,
379 step.approval_assignee_user_id
380 );
381 assert_eq!(back.approval_requirement, step.approval_requirement);
382 assert_eq!(back.approvals, step.approvals);
383 }
384
385 #[test]
386 fn step_serde_defaults_approval_fields_when_absent() {
387 let run_id = Uuid::now_v7();
388 let payload = json!({
389 "id": Uuid::now_v7(),
390 "trace_id": step_trace_id(run_id, "legacy", 0),
391 "run_id": run_id,
392 "name": "legacy",
393 "kind": "shell",
394 "position": 0,
395 "status": {"state": "pending", "state_machine_id": Uuid::now_v7()},
396 "input": null,
397 "output": null,
398 "error": null,
399 "duration_ms": 0,
400 "cost_usd": 0.0,
401 "input_tokens": null,
402 "cache_read_input_tokens": null,
403 "cache_creation_input_tokens": null,
404 "output_tokens": null,
405 "created_at": "2026-09-21T12:00:00Z",
406 "updated_at": "2026-09-21T12:00:00Z",
407 "started_at": null,
408 "completed_at": null,
409 "debug_messages": null
410 });
411
412 let step: Step = serde_json::from_value(payload).expect("deserialize");
413
414 assert_eq!(step.approval_stage, 0);
415 assert!(step.cache_read_input_tokens.is_none());
416 assert!(step.cache_creation_input_tokens.is_none());
417 assert!(step.approval_deadline_at.is_none());
418 assert!(step.approval_assignee.is_none());
419 assert!(step.approval_assignee_user_id.is_none());
420 assert!(step.approval_requirement.is_none());
421 assert!(step.approvals.is_empty());
422 assert!(step.environment_id.is_none());
423 assert!(step.session_id.is_none());
424 }
425
426 #[test]
427 fn stepupdate_default_is_no_changes() {
428 let update = StepUpdate::default();
429 assert!(update.status.is_none());
430 assert!(update.output.is_none());
431 assert!(update.error.is_none());
432 assert!(update.duration_ms.is_none());
433 assert!(update.cost_usd.is_none());
434 assert!(update.input_tokens.is_none());
435 assert!(update.cache_read_input_tokens.is_none());
436 assert!(update.cache_creation_input_tokens.is_none());
437 assert!(update.output_tokens.is_none());
438 assert!(update.started_at.is_none());
439 assert!(update.completed_at.is_none());
440 assert!(update.debug_messages.is_none());
441 assert!(update.approval_deadline_at.is_none());
442 assert!(update.approval_stage.is_none());
443 assert!(update.approval_assignee.is_none());
444 assert!(update.approval_requirement.is_none());
445 assert!(!update.clear_approval_deadline);
446 assert!(update.environment_id.is_none());
447 assert!(update.session_id.is_none());
448 }
449
450 #[test]
451 fn stepupdate_serde_roundtrip() {
452 let update = StepUpdate {
453 status: Some(StepStatus::Completed),
454 output: Some(json!({"result": "ok"})),
455 error: None,
456 duration_ms: Some(1000),
457 cost_usd: Some(Decimal::new(50, 2)),
458 input_tokens: Some(50),
459 cache_read_input_tokens: Some(2000),
460 cache_creation_input_tokens: Some(150),
461 output_tokens: Some(75),
462 started_at: None,
463 completed_at: None,
464 debug_messages: None,
465 approval_deadline_at: Some(Utc::now()),
466 approval_stage: Some(1),
467 approval_assignee: Some(Assignee::group("sre-oncall")),
468 approval_requirement: Some(ApprovalRequirement::default()),
469 clear_approval_deadline: false,
470 account_id: Some(Uuid::now_v7()),
471 environment_id: Some("ironflow-env-0a1b2c".to_string()),
472 session_id: Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string()),
473 };
474
475 let json = serde_json::to_string(&update).expect("serialize");
476 let back: StepUpdate = serde_json::from_str(&json).expect("deserialize");
477
478 assert_eq!(back.status, update.status);
479 assert_eq!(back.output, update.output);
480 assert_eq!(back.duration_ms, update.duration_ms);
481 assert_eq!(back.account_id, update.account_id);
482 assert_eq!(back.environment_id, update.environment_id);
483 assert_eq!(back.session_id, update.session_id);
484 assert_eq!(back.cost_usd, update.cost_usd);
485 assert_eq!(back.input_tokens, update.input_tokens);
486 assert_eq!(back.cache_read_input_tokens, update.cache_read_input_tokens);
487 assert_eq!(
488 back.cache_creation_input_tokens,
489 update.cache_creation_input_tokens
490 );
491 assert_eq!(back.output_tokens, update.output_tokens);
492 assert_eq!(back.approval_deadline_at, update.approval_deadline_at);
493 assert_eq!(back.approval_stage, update.approval_stage);
494 assert_eq!(back.approval_assignee, update.approval_assignee);
495 assert_eq!(back.approval_requirement, update.approval_requirement);
496 assert_eq!(back.clear_approval_deadline, update.clear_approval_deadline);
497 }
498
499 #[test]
500 fn stepupdate_deserializes_without_cache_fields() {
501 let payload = json!({
502 "status": "completed",
503 "output": null,
504 "error": null,
505 "duration_ms": 10,
506 "cost_usd": null,
507 "input_tokens": 12,
508 "output_tokens": 3,
509 "started_at": null,
510 "completed_at": null,
511 "debug_messages": null
512 });
513
514 let update: StepUpdate = serde_json::from_value(payload).expect("deserialize");
515
516 assert_eq!(update.input_tokens, Some(12));
517 assert!(update.cache_read_input_tokens.is_none());
518 assert!(update.cache_creation_input_tokens.is_none());
519 }
520
521 #[test]
522 fn trace_id_is_deterministic() {
523 let run_id = Uuid::nil();
524 let id1 = step_trace_id(run_id, "build", 0);
525 let id2 = step_trace_id(run_id, "build", 0);
526 assert_eq!(id1, id2);
527 }
528
529 #[test]
530 fn trace_id_differs_for_different_inputs() {
531 let run_id = Uuid::nil();
532 let a = step_trace_id(run_id, "build", 0);
533 let b = step_trace_id(run_id, "test", 0);
534 let c = step_trace_id(run_id, "build", 1);
535 let d = step_trace_id(Uuid::max(), "build", 0);
536
537 assert_ne!(a, b);
538 assert_ne!(a, c);
539 assert_ne!(a, d);
540 }
541
542 #[test]
543 fn trace_id_is_uuid_v5() {
544 let id = step_trace_id(Uuid::nil(), "build", 0);
545 assert_eq!(id.get_version_num(), 5);
546 }
547}