Skip to main content

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}