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
44pub const AGENT_ROLE_INPUT_KEY: &str = "role";
58
59pub fn agent_role_from_input(kind: &StepKind, input: Option<&Value>) -> Option<String> {
81 if *kind != StepKind::Agent {
82 return None;
83 }
84 input?
85 .get(AGENT_ROLE_INPUT_KEY)?
86 .as_str()
87 .filter(|role| !role.is_empty())
88 .map(str::to_string)
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct Step {
105 pub id: Uuid,
107 pub trace_id: Uuid,
112 pub run_id: Uuid,
114 pub name: String,
116 pub kind: StepKind,
118 pub position: u32,
125 pub status: FsmState<StepStatus>,
127 #[serde(default = "default_attempt")]
135 pub attempt: u32,
136 pub input: Option<Value>,
138 pub output: Option<Value>,
140 pub error: Option<String>,
142 pub duration_ms: u64,
144 pub cost_usd: Decimal,
146 pub input_tokens: Option<u64>,
148 #[serde(default)]
150 pub cache_read_input_tokens: Option<u64>,
151 #[serde(default)]
153 pub cache_creation_input_tokens: Option<u64>,
154 pub output_tokens: Option<u64>,
156 pub created_at: DateTime<Utc>,
158 pub updated_at: DateTime<Utc>,
160 pub started_at: Option<DateTime<Utc>>,
162 pub completed_at: Option<DateTime<Utc>>,
164 pub debug_messages: Option<Value>,
166 #[serde(default)]
168 pub is_error_handler: bool,
169 #[serde(default)]
174 pub approval_deadline_at: Option<DateTime<Utc>>,
175 #[serde(default)]
177 pub approval_stage: u32,
178 #[serde(default)]
180 pub approval_assignee: Option<Assignee>,
181 #[serde(default)]
187 pub approval_assignee_user_id: Option<Uuid>,
188 #[serde(default)]
191 pub approval_requirement: Option<ApprovalRequirement>,
192 #[serde(default)]
194 pub approvals: Vec<StepApproval>,
195 #[serde(default)]
197 pub account_id: Option<Uuid>,
198 #[serde(default)]
201 pub environment_id: Option<String>,
202 #[serde(default)]
205 pub session_id: Option<String>,
206 #[serde(default)]
212 pub agent_role: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct NewStep {
237 pub run_id: Uuid,
239 pub trace_id: Uuid,
241 pub name: String,
243 pub kind: StepKind,
245 pub position: u32,
247 pub input: Option<Value>,
249 #[serde(default)]
251 pub is_error_handler: bool,
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct StepUpdate {
272 pub status: Option<StepStatus>,
274 pub output: Option<Value>,
276 pub error: Option<String>,
278 pub duration_ms: Option<u64>,
280 pub cost_usd: Option<Decimal>,
282 pub input_tokens: Option<u64>,
284 #[serde(default)]
286 pub cache_read_input_tokens: Option<u64>,
287 #[serde(default)]
289 pub cache_creation_input_tokens: Option<u64>,
290 pub output_tokens: Option<u64>,
292 pub started_at: Option<DateTime<Utc>>,
294 pub completed_at: Option<DateTime<Utc>>,
296 pub debug_messages: Option<Value>,
298 #[serde(default)]
301 pub approval_deadline_at: Option<DateTime<Utc>>,
302 #[serde(default)]
304 pub approval_stage: Option<u32>,
305 #[serde(default)]
307 pub approval_assignee: Option<Assignee>,
308 #[serde(default)]
310 pub approval_requirement: Option<ApprovalRequirement>,
311 #[serde(default)]
314 pub clear_approval_deadline: bool,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub account_id: Option<Uuid>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub environment_id: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub session_id: Option<String>,
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use serde_json::json;
330
331 #[test]
332 fn newstep_serde_roundtrip() {
333 let new_step = NewStep {
334 run_id: Uuid::nil(),
335 trace_id: step_trace_id(Uuid::nil(), "build", 0),
336 name: "build".to_string(),
337 kind: StepKind::Shell,
338 position: 0,
339 input: Some(json!({"command": "cargo build"})),
340 is_error_handler: false,
341 };
342
343 let json = serde_json::to_string(&new_step).expect("serialize");
344 let back: NewStep = serde_json::from_str(&json).expect("deserialize");
345
346 assert_eq!(back.run_id, new_step.run_id);
347 assert_eq!(back.name, new_step.name);
348 assert_eq!(back.kind, new_step.kind);
349 assert_eq!(back.position, new_step.position);
350 assert_eq!(back.input, new_step.input);
351 }
352
353 #[test]
354 fn step_serde_preserves_all_fields() {
355 use crate::entities::FsmState;
356 use chrono::Utc;
357
358 let now = Utc::now();
359 let run_id = Uuid::now_v7();
360 let step = Step {
361 id: Uuid::now_v7(),
362 trace_id: step_trace_id(run_id, "test-step", 1),
363 run_id,
364 name: "test-step".to_string(),
365 kind: StepKind::Agent,
366 position: 1,
367 status: FsmState::new(StepStatus::Completed, Uuid::now_v7()),
368 attempt: 2,
369 input: Some(json!({"input": "data"})),
370 output: Some(json!({"output": "result"})),
371 error: None,
372 duration_ms: 2500,
373 cost_usd: Decimal::new(150, 2),
374 input_tokens: Some(100),
375 cache_read_input_tokens: Some(4000),
376 cache_creation_input_tokens: Some(300),
377 output_tokens: Some(200),
378 created_at: now,
379 updated_at: now,
380 started_at: Some(now),
381 completed_at: Some(now),
382 debug_messages: None,
383 is_error_handler: false,
384 approval_deadline_at: Some(now),
385 approval_stage: 2,
386 approval_assignee: Some(Assignee::group("sre-oncall")),
387 approval_assignee_user_id: Some(Uuid::now_v7()),
388 approval_requirement: Some(ApprovalRequirement {
389 reason: Some("amount > 10k".to_string()),
390 required_approvers: 2,
391 approver_groups: vec!["finance".to_string()],
392 }),
393 approvals: vec![StepApproval {
394 user_id: Uuid::now_v7(),
395 approved_by: "alice".to_string(),
396 at: now,
397 }],
398 account_id: Some(Uuid::now_v7()),
399 environment_id: Some("ironflow-env-0a1b2c".to_string()),
400 session_id: Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string()),
401 agent_role: Some("Reviewer".to_string()),
402 };
403
404 let json = serde_json::to_string(&step).expect("serialize");
405 let back: Step = serde_json::from_str(&json).expect("deserialize");
406
407 assert_eq!(back.id, step.id);
408 assert_eq!(back.run_id, step.run_id);
409 assert_eq!(back.name, step.name);
410 assert_eq!(back.kind, step.kind);
411 assert_eq!(back.position, step.position);
412 assert_eq!(back.status.state, step.status.state);
413 assert_eq!(back.attempt, step.attempt);
414 assert_eq!(back.input, step.input);
415 assert_eq!(back.output, step.output);
416 assert_eq!(back.error, step.error);
417 assert_eq!(back.duration_ms, step.duration_ms);
418 assert_eq!(back.cost_usd, step.cost_usd);
419 assert_eq!(back.input_tokens, step.input_tokens);
420 assert_eq!(back.cache_read_input_tokens, step.cache_read_input_tokens);
421 assert_eq!(
422 back.cache_creation_input_tokens,
423 step.cache_creation_input_tokens
424 );
425 assert_eq!(back.output_tokens, step.output_tokens);
426 assert_eq!(back.account_id, step.account_id);
427 assert_eq!(back.environment_id, step.environment_id);
428 assert_eq!(back.session_id, step.session_id);
429 assert_eq!(back.agent_role, step.agent_role);
430 assert_eq!(back.approval_deadline_at, step.approval_deadline_at);
431 assert_eq!(back.approval_stage, step.approval_stage);
432 assert_eq!(back.approval_assignee, step.approval_assignee);
433 assert_eq!(
434 back.approval_assignee_user_id,
435 step.approval_assignee_user_id
436 );
437 assert_eq!(back.approval_requirement, step.approval_requirement);
438 assert_eq!(back.approvals, step.approvals);
439 }
440
441 #[test]
442 fn step_serde_defaults_approval_fields_when_absent() {
443 let run_id = Uuid::now_v7();
444 let payload = json!({
445 "id": Uuid::now_v7(),
446 "trace_id": step_trace_id(run_id, "legacy", 0),
447 "run_id": run_id,
448 "name": "legacy",
449 "kind": "shell",
450 "position": 0,
451 "status": {"state": "pending", "state_machine_id": Uuid::now_v7()},
452 "input": null,
453 "output": null,
454 "error": null,
455 "duration_ms": 0,
456 "cost_usd": 0.0,
457 "input_tokens": null,
458 "cache_read_input_tokens": null,
459 "cache_creation_input_tokens": null,
460 "output_tokens": null,
461 "created_at": "2026-09-21T12:00:00Z",
462 "updated_at": "2026-09-21T12:00:00Z",
463 "started_at": null,
464 "completed_at": null,
465 "debug_messages": null
466 });
467
468 let step: Step = serde_json::from_value(payload).expect("deserialize");
469
470 assert_eq!(step.approval_stage, 0);
471 assert!(step.cache_read_input_tokens.is_none());
472 assert!(step.cache_creation_input_tokens.is_none());
473 assert!(step.approval_deadline_at.is_none());
474 assert!(step.approval_assignee.is_none());
475 assert!(step.approval_assignee_user_id.is_none());
476 assert!(step.approval_requirement.is_none());
477 assert!(step.approvals.is_empty());
478 assert!(step.environment_id.is_none());
479 assert!(step.session_id.is_none());
480 assert!(step.agent_role.is_none());
481 }
482
483 #[test]
484 fn stepupdate_default_is_no_changes() {
485 let update = StepUpdate::default();
486 assert!(update.status.is_none());
487 assert!(update.output.is_none());
488 assert!(update.error.is_none());
489 assert!(update.duration_ms.is_none());
490 assert!(update.cost_usd.is_none());
491 assert!(update.input_tokens.is_none());
492 assert!(update.cache_read_input_tokens.is_none());
493 assert!(update.cache_creation_input_tokens.is_none());
494 assert!(update.output_tokens.is_none());
495 assert!(update.started_at.is_none());
496 assert!(update.completed_at.is_none());
497 assert!(update.debug_messages.is_none());
498 assert!(update.approval_deadline_at.is_none());
499 assert!(update.approval_stage.is_none());
500 assert!(update.approval_assignee.is_none());
501 assert!(update.approval_requirement.is_none());
502 assert!(!update.clear_approval_deadline);
503 assert!(update.environment_id.is_none());
504 assert!(update.session_id.is_none());
505 }
506
507 #[test]
508 fn stepupdate_serde_roundtrip() {
509 let update = StepUpdate {
510 status: Some(StepStatus::Completed),
511 output: Some(json!({"result": "ok"})),
512 error: None,
513 duration_ms: Some(1000),
514 cost_usd: Some(Decimal::new(50, 2)),
515 input_tokens: Some(50),
516 cache_read_input_tokens: Some(2000),
517 cache_creation_input_tokens: Some(150),
518 output_tokens: Some(75),
519 started_at: None,
520 completed_at: None,
521 debug_messages: None,
522 approval_deadline_at: Some(Utc::now()),
523 approval_stage: Some(1),
524 approval_assignee: Some(Assignee::group("sre-oncall")),
525 approval_requirement: Some(ApprovalRequirement::default()),
526 clear_approval_deadline: false,
527 account_id: Some(Uuid::now_v7()),
528 environment_id: Some("ironflow-env-0a1b2c".to_string()),
529 session_id: Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string()),
530 };
531
532 let json = serde_json::to_string(&update).expect("serialize");
533 let back: StepUpdate = serde_json::from_str(&json).expect("deserialize");
534
535 assert_eq!(back.status, update.status);
536 assert_eq!(back.output, update.output);
537 assert_eq!(back.duration_ms, update.duration_ms);
538 assert_eq!(back.account_id, update.account_id);
539 assert_eq!(back.environment_id, update.environment_id);
540 assert_eq!(back.session_id, update.session_id);
541 assert_eq!(back.cost_usd, update.cost_usd);
542 assert_eq!(back.input_tokens, update.input_tokens);
543 assert_eq!(back.cache_read_input_tokens, update.cache_read_input_tokens);
544 assert_eq!(
545 back.cache_creation_input_tokens,
546 update.cache_creation_input_tokens
547 );
548 assert_eq!(back.output_tokens, update.output_tokens);
549 assert_eq!(back.approval_deadline_at, update.approval_deadline_at);
550 assert_eq!(back.approval_stage, update.approval_stage);
551 assert_eq!(back.approval_assignee, update.approval_assignee);
552 assert_eq!(back.approval_requirement, update.approval_requirement);
553 assert_eq!(back.clear_approval_deadline, update.clear_approval_deadline);
554 }
555
556 #[test]
557 fn stepupdate_deserializes_without_cache_fields() {
558 let payload = json!({
559 "status": "completed",
560 "output": null,
561 "error": null,
562 "duration_ms": 10,
563 "cost_usd": null,
564 "input_tokens": 12,
565 "output_tokens": 3,
566 "started_at": null,
567 "completed_at": null,
568 "debug_messages": null
569 });
570
571 let update: StepUpdate = serde_json::from_value(payload).expect("deserialize");
572
573 assert_eq!(update.input_tokens, Some(12));
574 assert!(update.cache_read_input_tokens.is_none());
575 assert!(update.cache_creation_input_tokens.is_none());
576 }
577
578 #[test]
579 fn trace_id_is_deterministic() {
580 let run_id = Uuid::nil();
581 let id1 = step_trace_id(run_id, "build", 0);
582 let id2 = step_trace_id(run_id, "build", 0);
583 assert_eq!(id1, id2);
584 }
585
586 #[test]
587 fn trace_id_differs_for_different_inputs() {
588 let run_id = Uuid::nil();
589 let a = step_trace_id(run_id, "build", 0);
590 let b = step_trace_id(run_id, "test", 0);
591 let c = step_trace_id(run_id, "build", 1);
592 let d = step_trace_id(Uuid::max(), "build", 0);
593
594 assert_ne!(a, b);
595 assert_ne!(a, c);
596 assert_ne!(a, d);
597 }
598
599 #[test]
600 fn trace_id_is_uuid_v5() {
601 let id = step_trace_id(Uuid::nil(), "build", 0);
602 assert_eq!(id.get_version_num(), 5);
603 }
604
605 #[test]
606 fn agent_role_is_read_from_an_agent_step_input() {
607 let input = json!({"type": "agent", "prompt": "p", "role": "Reviewer"});
608 assert_eq!(
609 agent_role_from_input(&StepKind::Agent, Some(&input)).as_deref(),
610 Some("Reviewer")
611 );
612 }
613
614 #[test]
615 fn agent_role_is_none_without_a_usable_role() {
616 for input in [
617 json!({"type": "agent", "prompt": "p"}),
618 json!({"type": "agent", "prompt": "p", "role": null}),
619 json!({"type": "agent", "prompt": "p", "role": 42}),
620 json!({"type": "agent", "prompt": "p", "role": ""}),
621 json!("not an object"),
622 ] {
623 assert_eq!(
624 agent_role_from_input(&StepKind::Agent, Some(&input)),
625 None,
626 "input: {input}"
627 );
628 }
629 }
630
631 #[test]
632 fn agent_role_is_none_for_another_kind() {
633 let input = json!({"type": "shell", "command": "ls", "role": "Reviewer"});
634 assert_eq!(agent_role_from_input(&StepKind::Shell, Some(&input)), None);
635 assert_eq!(
636 agent_role_from_input(&StepKind::Custom("x".to_string()), Some(&input)),
637 None
638 );
639 }
640
641 #[test]
642 fn agent_role_is_none_without_input() {
643 assert_eq!(agent_role_from_input(&StepKind::Agent, None), None);
644 }
645}