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")]
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 #[serde(default)]
101 pub cache_read_input_tokens: Option<u64>,
102 #[serde(default)]
104 pub cache_creation_input_tokens: Option<u64>,
105 pub output_tokens: Option<u64>,
107 pub created_at: DateTime<Utc>,
109 pub updated_at: DateTime<Utc>,
111 pub started_at: Option<DateTime<Utc>>,
113 pub completed_at: Option<DateTime<Utc>>,
115 pub debug_messages: Option<Value>,
117 #[serde(default)]
119 pub is_error_handler: bool,
120 #[serde(default)]
125 pub approval_deadline_at: Option<DateTime<Utc>>,
126 #[serde(default)]
128 pub approval_stage: u32,
129 #[serde(default)]
131 pub approval_assignee: Option<Assignee>,
132 #[serde(default)]
135 pub approval_requirement: Option<ApprovalRequirement>,
136 #[serde(default)]
138 pub approvals: Vec<StepApproval>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct NewStep {
163 pub run_id: Uuid,
165 pub trace_id: Uuid,
167 pub name: String,
169 pub kind: StepKind,
171 pub position: u32,
173 pub input: Option<Value>,
175 #[serde(default)]
177 pub is_error_handler: bool,
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct StepUpdate {
198 pub status: Option<StepStatus>,
200 pub output: Option<Value>,
202 pub error: Option<String>,
204 pub duration_ms: Option<u64>,
206 pub cost_usd: Option<Decimal>,
208 pub input_tokens: Option<u64>,
210 #[serde(default)]
212 pub cache_read_input_tokens: Option<u64>,
213 #[serde(default)]
215 pub cache_creation_input_tokens: Option<u64>,
216 pub output_tokens: Option<u64>,
218 pub started_at: Option<DateTime<Utc>>,
220 pub completed_at: Option<DateTime<Utc>>,
222 pub debug_messages: Option<Value>,
224 #[serde(default)]
227 pub approval_deadline_at: Option<DateTime<Utc>>,
228 #[serde(default)]
230 pub approval_stage: Option<u32>,
231 #[serde(default)]
233 pub approval_assignee: Option<Assignee>,
234 #[serde(default)]
236 pub approval_requirement: Option<ApprovalRequirement>,
237 #[serde(default)]
240 pub clear_approval_deadline: bool,
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use serde_json::json;
247
248 #[test]
249 fn newstep_serde_roundtrip() {
250 let new_step = NewStep {
251 run_id: Uuid::nil(),
252 trace_id: step_trace_id(Uuid::nil(), "build", 0),
253 name: "build".to_string(),
254 kind: StepKind::Shell,
255 position: 0,
256 input: Some(json!({"command": "cargo build"})),
257 is_error_handler: false,
258 };
259
260 let json = serde_json::to_string(&new_step).expect("serialize");
261 let back: NewStep = serde_json::from_str(&json).expect("deserialize");
262
263 assert_eq!(back.run_id, new_step.run_id);
264 assert_eq!(back.name, new_step.name);
265 assert_eq!(back.kind, new_step.kind);
266 assert_eq!(back.position, new_step.position);
267 assert_eq!(back.input, new_step.input);
268 }
269
270 #[test]
271 fn step_serde_preserves_all_fields() {
272 use crate::entities::FsmState;
273 use chrono::Utc;
274
275 let now = Utc::now();
276 let run_id = Uuid::now_v7();
277 let step = Step {
278 id: Uuid::now_v7(),
279 trace_id: step_trace_id(run_id, "test-step", 1),
280 run_id,
281 name: "test-step".to_string(),
282 kind: StepKind::Agent,
283 position: 1,
284 status: FsmState::new(StepStatus::Completed, Uuid::now_v7()),
285 attempt: 2,
286 input: Some(json!({"input": "data"})),
287 output: Some(json!({"output": "result"})),
288 error: None,
289 duration_ms: 2500,
290 cost_usd: Decimal::new(150, 2),
291 input_tokens: Some(100),
292 cache_read_input_tokens: Some(4000),
293 cache_creation_input_tokens: Some(300),
294 output_tokens: Some(200),
295 created_at: now,
296 updated_at: now,
297 started_at: Some(now),
298 completed_at: Some(now),
299 debug_messages: None,
300 is_error_handler: false,
301 approval_deadline_at: Some(now),
302 approval_stage: 2,
303 approval_assignee: Some(Assignee::group("sre-oncall")),
304 approval_requirement: Some(ApprovalRequirement {
305 reason: Some("amount > 10k".to_string()),
306 required_approvers: 2,
307 approver_groups: vec!["finance".to_string()],
308 }),
309 approvals: vec![StepApproval {
310 user_id: Uuid::now_v7(),
311 approved_by: "alice".to_string(),
312 at: now,
313 }],
314 };
315
316 let json = serde_json::to_string(&step).expect("serialize");
317 let back: Step = serde_json::from_str(&json).expect("deserialize");
318
319 assert_eq!(back.id, step.id);
320 assert_eq!(back.run_id, step.run_id);
321 assert_eq!(back.name, step.name);
322 assert_eq!(back.kind, step.kind);
323 assert_eq!(back.position, step.position);
324 assert_eq!(back.status.state, step.status.state);
325 assert_eq!(back.attempt, step.attempt);
326 assert_eq!(back.input, step.input);
327 assert_eq!(back.output, step.output);
328 assert_eq!(back.error, step.error);
329 assert_eq!(back.duration_ms, step.duration_ms);
330 assert_eq!(back.cost_usd, step.cost_usd);
331 assert_eq!(back.input_tokens, step.input_tokens);
332 assert_eq!(back.cache_read_input_tokens, step.cache_read_input_tokens);
333 assert_eq!(
334 back.cache_creation_input_tokens,
335 step.cache_creation_input_tokens
336 );
337 assert_eq!(back.output_tokens, step.output_tokens);
338 assert_eq!(back.approval_deadline_at, step.approval_deadline_at);
339 assert_eq!(back.approval_stage, step.approval_stage);
340 assert_eq!(back.approval_assignee, step.approval_assignee);
341 assert_eq!(back.approval_requirement, step.approval_requirement);
342 assert_eq!(back.approvals, step.approvals);
343 }
344
345 #[test]
346 fn step_serde_defaults_approval_fields_when_absent() {
347 let run_id = Uuid::now_v7();
348 let payload = json!({
349 "id": Uuid::now_v7(),
350 "trace_id": step_trace_id(run_id, "legacy", 0),
351 "run_id": run_id,
352 "name": "legacy",
353 "kind": "shell",
354 "position": 0,
355 "status": {"state": "pending", "state_machine_id": Uuid::now_v7()},
356 "input": null,
357 "output": null,
358 "error": null,
359 "duration_ms": 0,
360 "cost_usd": 0.0,
361 "input_tokens": null,
362 "cache_read_input_tokens": null,
363 "cache_creation_input_tokens": null,
364 "output_tokens": null,
365 "created_at": "2026-09-21T12:00:00Z",
366 "updated_at": "2026-09-21T12:00:00Z",
367 "started_at": null,
368 "completed_at": null,
369 "debug_messages": null
370 });
371
372 let step: Step = serde_json::from_value(payload).expect("deserialize");
373
374 assert_eq!(step.approval_stage, 0);
375 assert!(step.cache_read_input_tokens.is_none());
376 assert!(step.cache_creation_input_tokens.is_none());
377 assert!(step.approval_deadline_at.is_none());
378 assert!(step.approval_assignee.is_none());
379 assert!(step.approval_requirement.is_none());
380 assert!(step.approvals.is_empty());
381 }
382
383 #[test]
384 fn stepupdate_default_is_no_changes() {
385 let update = StepUpdate::default();
386 assert!(update.status.is_none());
387 assert!(update.output.is_none());
388 assert!(update.error.is_none());
389 assert!(update.duration_ms.is_none());
390 assert!(update.cost_usd.is_none());
391 assert!(update.input_tokens.is_none());
392 assert!(update.cache_read_input_tokens.is_none());
393 assert!(update.cache_creation_input_tokens.is_none());
394 assert!(update.output_tokens.is_none());
395 assert!(update.started_at.is_none());
396 assert!(update.completed_at.is_none());
397 assert!(update.debug_messages.is_none());
398 assert!(update.approval_deadline_at.is_none());
399 assert!(update.approval_stage.is_none());
400 assert!(update.approval_assignee.is_none());
401 assert!(update.approval_requirement.is_none());
402 assert!(!update.clear_approval_deadline);
403 }
404
405 #[test]
406 fn stepupdate_serde_roundtrip() {
407 let update = StepUpdate {
408 status: Some(StepStatus::Completed),
409 output: Some(json!({"result": "ok"})),
410 error: None,
411 duration_ms: Some(1000),
412 cost_usd: Some(Decimal::new(50, 2)),
413 input_tokens: Some(50),
414 cache_read_input_tokens: Some(2000),
415 cache_creation_input_tokens: Some(150),
416 output_tokens: Some(75),
417 started_at: None,
418 completed_at: None,
419 debug_messages: None,
420 approval_deadline_at: Some(Utc::now()),
421 approval_stage: Some(1),
422 approval_assignee: Some(Assignee::group("sre-oncall")),
423 approval_requirement: Some(ApprovalRequirement::default()),
424 clear_approval_deadline: false,
425 };
426
427 let json = serde_json::to_string(&update).expect("serialize");
428 let back: StepUpdate = serde_json::from_str(&json).expect("deserialize");
429
430 assert_eq!(back.status, update.status);
431 assert_eq!(back.output, update.output);
432 assert_eq!(back.duration_ms, update.duration_ms);
433 assert_eq!(back.cost_usd, update.cost_usd);
434 assert_eq!(back.input_tokens, update.input_tokens);
435 assert_eq!(back.cache_read_input_tokens, update.cache_read_input_tokens);
436 assert_eq!(
437 back.cache_creation_input_tokens,
438 update.cache_creation_input_tokens
439 );
440 assert_eq!(back.output_tokens, update.output_tokens);
441 assert_eq!(back.approval_deadline_at, update.approval_deadline_at);
442 assert_eq!(back.approval_stage, update.approval_stage);
443 assert_eq!(back.approval_assignee, update.approval_assignee);
444 assert_eq!(back.approval_requirement, update.approval_requirement);
445 assert_eq!(back.clear_approval_deadline, update.clear_approval_deadline);
446 }
447
448 #[test]
449 fn stepupdate_deserializes_without_cache_fields() {
450 let payload = json!({
451 "status": "completed",
452 "output": null,
453 "error": null,
454 "duration_ms": 10,
455 "cost_usd": null,
456 "input_tokens": 12,
457 "output_tokens": 3,
458 "started_at": null,
459 "completed_at": null,
460 "debug_messages": null
461 });
462
463 let update: StepUpdate = serde_json::from_value(payload).expect("deserialize");
464
465 assert_eq!(update.input_tokens, Some(12));
466 assert!(update.cache_read_input_tokens.is_none());
467 assert!(update.cache_creation_input_tokens.is_none());
468 }
469
470 #[test]
471 fn trace_id_is_deterministic() {
472 let run_id = Uuid::nil();
473 let id1 = step_trace_id(run_id, "build", 0);
474 let id2 = step_trace_id(run_id, "build", 0);
475 assert_eq!(id1, id2);
476 }
477
478 #[test]
479 fn trace_id_differs_for_different_inputs() {
480 let run_id = Uuid::nil();
481 let a = step_trace_id(run_id, "build", 0);
482 let b = step_trace_id(run_id, "test", 0);
483 let c = step_trace_id(run_id, "build", 1);
484 let d = step_trace_id(Uuid::max(), "build", 0);
485
486 assert_ne!(a, b);
487 assert_ne!(a, c);
488 assert_ne!(a, d);
489 }
490
491 #[test]
492 fn trace_id_is_uuid_v5() {
493 let id = step_trace_id(Uuid::nil(), "build", 0);
494 assert_eq!(id.get_version_num(), 5);
495 }
496}