ironflow_store/entities/run.rs
1//! [`Run`] entity and related request/update types.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::time::Duration;
6
7use chrono::{DateTime, TimeDelta, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use uuid::Uuid;
12
13use super::{FsmState, RunActor, RunStatus, TriggerKind};
14
15/// A workflow execution record.
16///
17/// Represents a single invocation of a workflow, tracking its status through
18/// the [`RunStatus`] FSM (SQL-side via [`lib_fsm`](crate::postgres::helpers::lib_fsm)),
19/// aggregated metrics, and timestamps.
20///
21/// # Examples
22///
23/// ```
24/// use ironflow_store::entities::Run;
25///
26/// // Runs are created by RunStore::create_run, not directly.
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct Run {
31 /// Unique identifier (UUIDv7, sortable by creation time).
32 pub id: Uuid,
33 /// Name of the workflow that was executed.
34 pub workflow_name: String,
35 /// Current FSM status — embeds state + state_machine_id for SQL-side transitions.
36 pub status: FsmState<RunStatus>,
37 /// How this run was triggered.
38 pub trigger: TriggerKind,
39 /// Trigger-specific payload (e.g. webhook body).
40 pub payload: Value,
41 /// Error message if the run failed.
42 pub error: Option<String>,
43 /// Number of times this run has been retried.
44 pub retry_count: u32,
45 /// Maximum number of retries allowed.
46 pub max_retries: u32,
47 /// Aggregated cost across all agent steps, in USD.
48 pub cost_usd: Decimal,
49 /// Aggregated wall-clock duration across all steps, in milliseconds.
50 pub duration_ms: u64,
51 /// When the run was created (enqueued).
52 pub created_at: DateTime<Utc>,
53 /// When the run record was last updated.
54 pub updated_at: DateTime<Utc>,
55 /// When execution started (transitioned to Running).
56 pub started_at: Option<DateTime<Utc>>,
57 /// When execution finished (transitioned to a terminal state).
58 pub completed_at: Option<DateTime<Utc>>,
59 /// Version of the handler that created this run.
60 pub handler_version: Option<String>,
61 /// User-defined key-value labels for categorization and filtering.
62 #[serde(default)]
63 pub labels: HashMap<String, String>,
64 /// When the run should start executing. `None` means immediately.
65 #[serde(default)]
66 pub scheduled_at: Option<DateTime<Utc>>,
67 /// The authenticated principal that created this run.
68 ///
69 /// `None` for cron, webhook, and programmatic triggers.
70 #[serde(default)]
71 pub created_by: Option<RunActor>,
72 /// Human-readable label for [`Run::created_by`].
73 ///
74 /// Read-only projection resolved at read time from the referenced user and
75 /// API key — never written by [`crate::store::RunStore::create_run`]. `None`
76 /// when there is no actor, or when the referenced user or key no longer exists.
77 #[serde(default)]
78 pub created_by_label: Option<String>,
79 /// Client-supplied idempotency key that produced this run, if any.
80 ///
81 /// See [`IDEMPOTENCY_WINDOW`] for how long a key stays bound to its run.
82 #[serde(default)]
83 pub idempotency_key: Option<String>,
84 /// Maximum cumulative cost allowed for this run, in USD.
85 ///
86 /// Resolved once at run creation and frozen for the lifetime of the run.
87 /// `None` means no cap.
88 #[serde(default)]
89 pub max_cost_usd: Option<Decimal>,
90 /// Identifier of the worker currently holding the lease on this run.
91 ///
92 /// Set when a worker picks the run up, cleared as soon as the run leaves
93 /// `Running`. `None` means no worker owns this run (runs executed inline or
94 /// resumed in-process by the API server never hold a lease).
95 #[serde(default)]
96 pub worker_id: Option<String>,
97 /// When the worker lease expires.
98 ///
99 /// The worker refreshes this while it executes the run. Once it is in the
100 /// past, the reaper may requeue the run.
101 #[serde(default)]
102 pub lease_expires_at: Option<DateTime<Utc>>,
103}
104
105/// How long a client-supplied idempotency key stays bound to its run.
106///
107/// Past this window a replayed key no longer resolves to the original run:
108/// the key is released and a fresh run is created.
109///
110/// # Examples
111///
112/// ```
113/// use ironflow_store::entities::IDEMPOTENCY_WINDOW;
114///
115/// assert_eq!(IDEMPOTENCY_WINDOW.num_hours(), 24);
116/// ```
117pub const IDEMPOTENCY_WINDOW: TimeDelta = TimeDelta::hours(24);
118
119/// Maximum accepted length of an idempotency key, in bytes.
120///
121/// # Examples
122///
123/// ```
124/// use ironflow_store::entities::MAX_IDEMPOTENCY_KEY_LEN;
125///
126/// assert_eq!(MAX_IDEMPOTENCY_KEY_LEN, 255);
127/// ```
128pub const MAX_IDEMPOTENCY_KEY_LEN: usize = 255;
129
130/// Outcome of [`RunStore::create_run`](crate::store::RunStore::create_run).
131///
132/// A request carrying an idempotency key already bound to a live run does not
133/// insert anything: the store returns the original run as [`RunCreation::Existing`].
134///
135/// # Examples
136///
137/// ```
138/// use std::collections::HashMap;
139/// use ironflow_store::entities::{NewRun, RunCreation, TriggerKind};
140/// use ironflow_store::memory::InMemoryStore;
141/// use ironflow_store::store::RunStore;
142/// use serde_json::json;
143///
144/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
145/// let store = InMemoryStore::new();
146/// let req = NewRun {
147/// workflow_name: "deploy".to_string(),
148/// trigger: TriggerKind::Manual,
149/// payload: json!({}),
150/// max_retries: 3,
151/// handler_version: None,
152/// labels: HashMap::new(),
153/// scheduled_at: None,
154/// created_by: None,
155/// idempotency_key: Some("deploy-2026-07-26".to_string()),
156/// max_cost_usd: None,
157/// };
158///
159/// assert!(store.create_run(req.clone()).await?.is_created());
160/// assert!(!store.create_run(req).await?.is_created());
161/// # Ok(())
162/// # }
163/// ```
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub enum RunCreation {
166 /// A new run was inserted.
167 Created(Run),
168 /// The idempotency key already resolved to this run; nothing was inserted.
169 Existing(Run),
170}
171
172impl RunCreation {
173 /// Return the run, discarding whether it was created or replayed.
174 ///
175 /// # Examples
176 ///
177 /// ```no_run
178 /// # use ironflow_store::entities::RunCreation;
179 /// # fn example(creation: RunCreation) {
180 /// let run = creation.into_run();
181 /// # }
182 /// ```
183 pub fn into_run(self) -> Run {
184 match self {
185 RunCreation::Created(run) | RunCreation::Existing(run) => run,
186 }
187 }
188
189 /// Borrow the run, discarding whether it was created or replayed.
190 ///
191 /// # Examples
192 ///
193 /// ```no_run
194 /// # use ironflow_store::entities::RunCreation;
195 /// # fn example(creation: &RunCreation) {
196 /// let id = creation.run().id;
197 /// # }
198 /// ```
199 pub fn run(&self) -> &Run {
200 match self {
201 RunCreation::Created(run) | RunCreation::Existing(run) => run,
202 }
203 }
204
205 /// Whether a new run was actually inserted.
206 ///
207 /// # Examples
208 ///
209 /// ```no_run
210 /// # use ironflow_store::entities::RunCreation;
211 /// # fn example(creation: &RunCreation) {
212 /// if creation.is_created() {
213 /// // publish a RunCreated event
214 /// }
215 /// # }
216 /// ```
217 pub fn is_created(&self) -> bool {
218 matches!(self, RunCreation::Created(_))
219 }
220}
221
222/// Request to acquire or renew a worker lease on a run.
223///
224/// # Examples
225///
226/// ```
227/// use std::time::Duration;
228/// use ironflow_store::entities::LeaseRequest;
229///
230/// let lease = LeaseRequest {
231/// worker_id: "worker-1".to_string(),
232/// ttl: Duration::from_secs(90),
233/// };
234/// assert_eq!(lease.ttl.as_secs(), 90);
235/// ```
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct LeaseRequest {
238 /// Identifier of the worker acquiring the lease.
239 pub worker_id: String,
240 /// How long the lease stays valid without a refresh.
241 pub ttl: Duration,
242}
243
244impl LeaseRequest {
245 /// Compute the lease expiry from a reference instant.
246 ///
247 /// A TTL too large to be represented saturates to
248 /// [`DateTime::<Utc>::MAX_UTC`] instead of panicking.
249 ///
250 /// # Examples
251 ///
252 /// ```
253 /// use std::time::Duration;
254 /// use chrono::{TimeZone, Utc};
255 /// use ironflow_store::entities::LeaseRequest;
256 ///
257 /// let lease = LeaseRequest {
258 /// worker_id: "worker-1".to_string(),
259 /// ttl: Duration::from_secs(90),
260 /// };
261 /// let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
262 /// assert_eq!(lease.expires_at(now).timestamp(), now.timestamp() + 90);
263 /// ```
264 pub fn expires_at(&self, from: DateTime<Utc>) -> DateTime<Utc> {
265 TimeDelta::from_std(self.ttl)
266 .ok()
267 .and_then(|ttl| from.checked_add_signed(ttl))
268 .unwrap_or(DateTime::<Utc>::MAX_UTC)
269 }
270}
271
272/// A run recovered by the reaper after its worker lease expired.
273///
274/// # Examples
275///
276/// ```
277/// use ironflow_store::entities::{ReapedRun, RunStatus};
278///
279/// // Reaped runs are produced by RunStore::reap_expired_leases.
280/// fn was_requeued(reaped: &ReapedRun) -> bool {
281/// reaped.to == RunStatus::Pending
282/// }
283/// ```
284#[derive(Debug, Clone)]
285#[non_exhaustive]
286pub struct ReapedRun {
287 /// The run after recovery.
288 pub run: Run,
289 /// Status the run held before recovery (always [`RunStatus::Running`]).
290 pub from: RunStatus,
291 /// Status the run was moved to: [`RunStatus::Pending`] when retries remain,
292 /// [`RunStatus::Failed`] once `max_retries` is exhausted.
293 pub to: RunStatus,
294}
295
296/// Request to create a new run.
297///
298/// # Examples
299///
300/// ```
301/// use std::collections::HashMap;
302/// use ironflow_store::entities::{NewRun, TriggerKind};
303/// use serde_json::json;
304///
305/// let req = NewRun {
306/// workflow_name: "deploy".to_string(),
307/// trigger: TriggerKind::Manual,
308/// payload: json!({}),
309/// max_retries: 3,
310/// handler_version: None,
311/// labels: HashMap::new(),
312/// scheduled_at: None,
313/// created_by: None,
314/// idempotency_key: None,
315/// max_cost_usd: None,
316/// };
317/// ```
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct NewRun {
320 /// Workflow name.
321 pub workflow_name: String,
322 /// How the run was triggered.
323 pub trigger: TriggerKind,
324 /// Trigger-specific payload.
325 pub payload: Value,
326 /// Maximum retry attempts.
327 pub max_retries: u32,
328 /// Version of the handler at the time of run creation.
329 pub handler_version: Option<String>,
330 /// User-defined key-value labels for categorization and filtering.
331 #[serde(default)]
332 pub labels: HashMap<String, String>,
333 /// When the run should start executing. `None` means immediately.
334 #[serde(default)]
335 pub scheduled_at: Option<DateTime<Utc>>,
336 /// The authenticated principal creating this run.
337 ///
338 /// Defaults to `None` when absent from the payload, so an older worker that
339 /// does not send the field keeps working against a newer API.
340 #[serde(default)]
341 pub created_by: Option<RunActor>,
342 /// Optional idempotency key binding this request to a single run.
343 ///
344 /// When set and already bound to a run created within [`IDEMPOTENCY_WINDOW`],
345 /// the store returns that run instead of inserting a new one.
346 #[serde(default)]
347 pub idempotency_key: Option<String>,
348 /// Maximum cumulative cost allowed for this run, in USD. `None` means no cap.
349 #[serde(default)]
350 pub max_cost_usd: Option<Decimal>,
351}
352
353/// Filters for listing runs.
354///
355/// All fields are optional; `None` means "no filter" for that field.
356///
357/// # Examples
358///
359/// ```
360/// use ironflow_store::entities::{RunFilter, RunStatus};
361///
362/// let filter = RunFilter {
363/// workflow_name: Some("deploy".to_string()),
364/// status: Some(RunStatus::Completed),
365/// ..RunFilter::default()
366/// };
367/// ```
368#[derive(Debug, Clone, Default)]
369pub struct RunFilter {
370 /// Filter by workflow name (exact match).
371 pub workflow_name: Option<String>,
372 /// Filter by run status.
373 pub status: Option<RunStatus>,
374 /// Only include runs created after this timestamp.
375 pub created_after: Option<DateTime<Utc>>,
376 /// Only include runs created before this timestamp.
377 pub created_before: Option<DateTime<Utc>>,
378 /// When `Some(true)`, only include runs that have at least one step.
379 /// When `Some(false)`, only include runs with no steps.
380 /// When `None`, no filtering on steps.
381 pub has_steps: Option<bool>,
382 /// Filter by label key-value pair. Only include runs that have ALL specified labels.
383 pub labels: Option<HashMap<String, String>>,
384 /// Filter by author. Matches runs created by this user directly, and runs
385 /// created by one of this user's API keys.
386 pub created_by_user_id: Option<Uuid>,
387}
388
389/// Partial update for a run.
390///
391/// # Examples
392///
393/// ```
394/// use ironflow_store::entities::{RunUpdate, RunStatus};
395///
396/// let update = RunUpdate {
397/// status: Some(RunStatus::Completed),
398/// ..RunUpdate::default()
399/// };
400/// ```
401#[derive(Debug, Clone, Default, Serialize, Deserialize)]
402pub struct RunUpdate {
403 /// New status.
404 pub status: Option<RunStatus>,
405 /// Error message.
406 pub error: Option<String>,
407 /// Increment retry count.
408 pub increment_retry: bool,
409 /// Aggregated cost.
410 pub cost_usd: Option<Decimal>,
411 /// Aggregated duration.
412 pub duration_ms: Option<u64>,
413 /// When execution started.
414 pub started_at: Option<DateTime<Utc>>,
415 /// When execution completed.
416 pub completed_at: Option<DateTime<Utc>>,
417 /// When the run should next be picked up. Used to arm the retry backoff.
418 #[serde(default)]
419 pub scheduled_at: Option<DateTime<Utc>>,
420}
421
422/// Retention policy for purging old runs.
423///
424/// Runs are eligible for purging when they are in a terminal state
425/// ([`RunStatus::is_terminal`]) **and** exceed either the age limit or the
426/// per-workflow count limit.
427///
428/// # Examples
429///
430/// ```
431/// use ironflow_store::entities::PurgePolicy;
432///
433/// let policy = PurgePolicy {
434/// max_age_days: 90,
435/// max_runs_per_workflow: 1000,
436/// dry_run: false,
437/// };
438/// assert_eq!(policy.max_age_days, 90);
439/// ```
440#[derive(Debug, Clone)]
441pub struct PurgePolicy {
442 /// Runs older than this many days are eligible for purging.
443 pub max_age_days: u32,
444 /// When a workflow has more runs than this, the oldest terminal runs are
445 /// eligible for purging.
446 pub max_runs_per_workflow: u32,
447 /// When `true`, the purger logs what would be deleted but does not delete.
448 pub dry_run: bool,
449}
450
451/// Why a run was selected for purging.
452///
453/// # Examples
454///
455/// ```
456/// use ironflow_store::entities::PurgeReason;
457///
458/// let reason = PurgeReason::TooOld;
459/// assert_eq!(format!("{reason}"), "too_old");
460/// ```
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum PurgeReason {
463 /// The run exceeded [`PurgePolicy::max_age_days`].
464 TooOld,
465 /// The workflow exceeded [`PurgePolicy::max_runs_per_workflow`].
466 ExceedsWorkflowLimit,
467}
468
469impl fmt::Display for PurgeReason {
470 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471 match self {
472 PurgeReason::TooOld => f.write_str("too_old"),
473 PurgeReason::ExceedsWorkflowLimit => f.write_str("exceeds_workflow_limit"),
474 }
475 }
476}
477
478/// A run selected for purging by [`RunStore::list_purgeable_runs`](crate::store::RunStore::list_purgeable_runs).
479///
480/// # Examples
481///
482/// ```
483/// use ironflow_store::entities::{PurgeReason, PurgeableRun};
484/// use uuid::Uuid;
485///
486/// let purgeable = PurgeableRun {
487/// run_id: Uuid::now_v7(),
488/// workflow_name: "deploy".to_string(),
489/// reason: PurgeReason::TooOld,
490/// };
491/// assert_eq!(purgeable.reason, PurgeReason::TooOld);
492/// ```
493#[derive(Debug, Clone)]
494pub struct PurgeableRun {
495 /// The run to purge.
496 pub run_id: Uuid,
497 /// Workflow the run belongs to.
498 pub workflow_name: String,
499 /// Why this run was selected.
500 pub reason: PurgeReason,
501}
502
503#[cfg(test)]
504mod tests {
505 use std::collections::HashMap;
506
507 use super::*;
508 use serde_json::json;
509
510 #[test]
511 fn newrun_serde_roundtrip() {
512 let new_run = NewRun {
513 created_by: None,
514 workflow_name: "deploy".to_string(),
515 trigger: TriggerKind::Manual,
516 payload: json!({"key": "value"}),
517 max_retries: 3,
518 handler_version: Some("1.2.0".to_string()),
519 labels: HashMap::from([("env".to_string(), "prod".to_string())]),
520 scheduled_at: None,
521 idempotency_key: None,
522 max_cost_usd: Some(Decimal::new(250, 2)),
523 };
524
525 let json = serde_json::to_string(&new_run).expect("serialize");
526 let back: NewRun = serde_json::from_str(&json).expect("deserialize");
527 assert_eq!(back.max_cost_usd, new_run.max_cost_usd);
528 assert_eq!(back.workflow_name, new_run.workflow_name);
529 assert_eq!(back.trigger, new_run.trigger);
530 assert_eq!(back.payload, new_run.payload);
531 assert_eq!(back.max_retries, new_run.max_retries);
532 assert_eq!(back.handler_version, new_run.handler_version);
533 assert_eq!(back.labels, new_run.labels);
534 assert_eq!(back.scheduled_at, new_run.scheduled_at);
535 assert_eq!(back.created_by, new_run.created_by);
536 assert_eq!(back.idempotency_key, new_run.idempotency_key);
537 }
538
539 #[test]
540 fn newrun_serde_roundtrip_with_actor() {
541 let actor = RunActor::ApiKey {
542 api_key_id: Uuid::now_v7(),
543 user_id: Uuid::now_v7(),
544 };
545 let new_run = NewRun {
546 workflow_name: "deploy".to_string(),
547 trigger: TriggerKind::Api,
548 payload: json!({}),
549 max_retries: 0,
550 handler_version: None,
551 labels: HashMap::new(),
552 scheduled_at: None,
553 created_by: Some(actor.clone()),
554 idempotency_key: None,
555 max_cost_usd: None,
556 };
557
558 let json = serde_json::to_string(&new_run).expect("serialize");
559 let back: NewRun = serde_json::from_str(&json).expect("deserialize");
560 assert_eq!(back.created_by, Some(actor));
561 }
562
563 #[test]
564 fn newrun_deserializes_without_created_by() {
565 // An older worker POSTs a payload with no `created_by` field.
566 let raw = json!({
567 "workflow_name": "deploy",
568 "trigger": {"kind": "workflow"},
569 "payload": {},
570 "max_retries": 0,
571 "handler_version": null,
572 });
573
574 let new_run: NewRun = serde_json::from_value(raw).expect("deserialize");
575 assert!(new_run.created_by.is_none());
576 }
577
578 #[test]
579 fn run_serde_preserves_all_fields() {
580 use crate::entities::FsmState;
581 use chrono::Utc;
582 use uuid::Uuid;
583
584 let now = Utc::now();
585 let run = Run {
586 id: Uuid::now_v7(),
587 workflow_name: "test-wf".to_string(),
588 status: FsmState::new(RunStatus::Running, Uuid::now_v7()),
589 trigger: TriggerKind::Webhook {
590 path: "/hooks/test".to_string(),
591 },
592 payload: json!({"data": 123}),
593 error: Some("test error".to_string()),
594 retry_count: 2,
595 max_retries: 5,
596 cost_usd: Decimal::new(1234, 2),
597 duration_ms: 5000,
598 created_at: now,
599 updated_at: now,
600 started_at: Some(now),
601 completed_at: Some(now),
602 handler_version: Some("2.0.0".to_string()),
603 labels: HashMap::from([
604 ("env".to_string(), "staging".to_string()),
605 ("team".to_string(), "platform".to_string()),
606 ]),
607 scheduled_at: Some(now),
608 created_by: Some(RunActor::User {
609 user_id: Uuid::now_v7(),
610 }),
611 created_by_label: Some("alice".to_string()),
612 idempotency_key: Some("gh:abc-123".to_string()),
613 max_cost_usd: Some(Decimal::new(500, 2)),
614 worker_id: Some("worker-1".to_string()),
615 lease_expires_at: Some(now),
616 };
617
618 let json = serde_json::to_string(&run).expect("serialize");
619 let back: Run = serde_json::from_str(&json).expect("deserialize");
620
621 assert_eq!(back.id, run.id);
622 assert_eq!(back.workflow_name, run.workflow_name);
623 assert_eq!(back.status.state, run.status.state);
624 assert_eq!(back.trigger, run.trigger);
625 assert_eq!(back.payload, run.payload);
626 assert_eq!(back.error, run.error);
627 assert_eq!(back.retry_count, run.retry_count);
628 assert_eq!(back.max_retries, run.max_retries);
629 assert_eq!(back.cost_usd, run.cost_usd);
630 assert_eq!(back.duration_ms, run.duration_ms);
631 assert_eq!(back.started_at, run.started_at);
632 assert_eq!(back.completed_at, run.completed_at);
633 assert_eq!(back.handler_version, run.handler_version);
634 assert_eq!(back.labels, run.labels);
635 assert_eq!(back.scheduled_at, run.scheduled_at);
636 assert_eq!(back.created_by, run.created_by);
637 assert_eq!(back.created_by_label, run.created_by_label);
638 assert_eq!(back.idempotency_key, run.idempotency_key);
639 assert_eq!(back.max_cost_usd, run.max_cost_usd);
640 assert_eq!(back.worker_id, run.worker_id);
641 assert_eq!(back.lease_expires_at, run.lease_expires_at);
642 }
643
644 #[test]
645 fn newrun_max_cost_usd_defaults_to_none_when_absent() {
646 let without_cap = NewRun {
647 workflow_name: "deploy".to_string(),
648 trigger: TriggerKind::Manual,
649 payload: json!({}),
650 max_retries: 0,
651 handler_version: None,
652 labels: HashMap::new(),
653 scheduled_at: None,
654 created_by: None,
655 idempotency_key: None,
656 max_cost_usd: None,
657 };
658 let mut value = serde_json::to_value(&without_cap).expect("serialize");
659 value
660 .as_object_mut()
661 .expect("object")
662 .remove("max_cost_usd");
663
664 let parsed: NewRun = serde_json::from_value(value).expect("deserialize");
665 assert!(parsed.max_cost_usd.is_none());
666 }
667
668 #[test]
669 fn runupdate_serde_roundtrip() {
670 let update = RunUpdate {
671 status: Some(RunStatus::Completed),
672 error: Some("test error".to_string()),
673 increment_retry: true,
674 cost_usd: Some(Decimal::new(5000, 2)),
675 duration_ms: Some(3000),
676 started_at: None,
677 completed_at: None,
678 scheduled_at: Some(Utc::now()),
679 };
680
681 let json = serde_json::to_string(&update).expect("serialize");
682 let back: RunUpdate = serde_json::from_str(&json).expect("deserialize");
683
684 assert_eq!(back.status, update.status);
685 assert_eq!(back.error, update.error);
686 assert_eq!(back.increment_retry, update.increment_retry);
687 assert_eq!(back.cost_usd, update.cost_usd);
688 assert_eq!(back.duration_ms, update.duration_ms);
689 assert_eq!(back.scheduled_at, update.scheduled_at);
690 }
691
692 #[test]
693 fn runfilter_default_is_no_filters() {
694 let filter = RunFilter::default();
695 assert!(filter.workflow_name.is_none());
696 assert!(filter.status.is_none());
697 assert!(filter.created_after.is_none());
698 assert!(filter.created_before.is_none());
699 assert!(filter.created_by_user_id.is_none());
700 }
701
702 #[test]
703 fn runfilter_with_multiple_criteria() {
704 let filter = RunFilter {
705 workflow_name: Some("deploy".to_string()),
706 status: Some(RunStatus::Running),
707 ..RunFilter::default()
708 };
709
710 assert_eq!(filter.workflow_name, Some("deploy".to_string()));
711 assert_eq!(filter.status, Some(RunStatus::Running));
712 assert!(filter.created_after.is_none());
713 assert!(filter.created_before.is_none());
714 }
715}