Skip to main content

ironflow_store/
store.rs

1//! The [`RunStore`] trait — async storage abstraction for runs and steps.
2//!
3//! Implement this trait to plug in any backing store. Built-in implementations:
4//!
5//! - [`InMemoryStore`](crate::memory::InMemoryStore) — development and testing.
6//! - `PostgresStore` — production (behind the `store-postgres` feature).
7
8use std::future::Future;
9use std::pin::Pin;
10
11use chrono::{DateTime, Utc};
12use uuid::Uuid;
13
14use crate::agent_role_store::AgentRoleStore;
15use crate::api_key_store::ApiKeyStore;
16use crate::approval_delegation_store::ApprovalDelegationStore;
17use crate::artifact_store::ArtifactStore;
18use crate::audit_log_store::AuditLogStore;
19use crate::entities::{
20    AgentRoleStats, ConcurrencyGroupBacklog, LeaseRequest, NewRun, NewStep, NewStepDependency,
21    Page, PurgePolicy, PurgeableRun, ReapedRun, Run, RunCreation, RunFilter, RunStats, RunStatus,
22    RunUpdate, StatsHistoryBucket, StatsHistoryFilter, Step, StepApproval, StepDependency,
23    StepUpdate, WorkerCapabilities, WorkflowPause,
24};
25use crate::error::StoreError;
26use crate::log_store::LogStore;
27use crate::provider_account_store::ProviderAccountStore;
28use crate::schedule_store::ScheduleStore;
29use crate::secret_store::SecretStore;
30use crate::signal_store::SignalStore;
31use crate::user_store::UserStore;
32
33/// Boxed future for [`RunStore`] methods — ensures object safety for `dyn RunStore`.
34pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>>;
35
36/// Error recorded on a run that exhausted its retries through lease expiries.
37///
38/// Set by [`RunStore::reap_expired_leases`] when a run has been recovered more
39/// than `max_retries` times.
40pub const LEASE_EXPIRED_ERROR: &str = "worker lease expired";
41
42/// Error recorded on a step that was running when its worker lost the lease.
43///
44/// Set by the reaper on the `Running` steps of a run that
45/// [`RunStore::reap_expired_leases`] requeued. The engine executes such a step
46/// again at the same position when the run is picked up, keeping the
47/// interrupted record in the step history.
48///
49/// # Examples
50///
51/// ```
52/// use ironflow_store::store::{LEASE_EXPIRED_ERROR, STEP_INTERRUPTED_ERROR};
53///
54/// assert_eq!(STEP_INTERRUPTED_ERROR, "interrupted: worker lease lost");
55/// assert_ne!(STEP_INTERRUPTED_ERROR, LEASE_EXPIRED_ERROR);
56/// ```
57pub const STEP_INTERRUPTED_ERROR: &str = "interrupted: worker lease lost";
58
59/// Async storage abstraction for workflow runs and steps.
60///
61/// All methods return a [`StoreFuture`] (boxed future) to maintain object safety,
62/// allowing the store to be used as `Arc<dyn RunStore>`.
63///
64/// # Examples
65///
66/// ```no_run
67/// use std::collections::HashMap;
68/// use ironflow_store::prelude::*;
69/// use serde_json::json;
70/// use uuid::Uuid;
71///
72/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
73/// let store = InMemoryStore::new();
74///
75/// let run = store.create_run(NewRun {
76///     workflow_name: "deploy".to_string(),
77///     trigger: TriggerKind::Manual,
78///     payload: json!({}),
79///     max_retries: 3,
80///     handler_version: None,
81///     labels: HashMap::new(),
82///     scheduled_at: None,
83///     created_by: None,
84///     idempotency_key: None,
85///     concurrency_key: None,
86///     priority: 0,
87///     concurrency_limits: Vec::new(),
88///     max_cost_usd: None,
89///     worker_tags: Vec::new(),
90/// }).await?.into_run();
91///
92/// let fetched = store.get_run(run.id).await?;
93/// assert!(fetched.is_some());
94/// # Ok(())
95/// # }
96/// ```
97pub trait RunStore: Send + Sync {
98    /// Create a new run in `Pending` status.
99    ///
100    /// When [`NewRun::idempotency_key`] is set and already bound to a run created
101    /// within [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW), nothing is
102    /// inserted and that run is returned as [`RunCreation::Existing`]. A key bound to
103    /// an older run is released and reused for the new one.
104    ///
105    /// Concurrent calls sharing the same key resolve to a single run: exactly one
106    /// receives [`RunCreation::Created`], the others [`RunCreation::Existing`].
107    ///
108    /// When [`NewRun::concurrency_key`] is set, the idempotency lookup runs first,
109    /// then the key is checked: concurrent calls sharing it are serialized, and
110    /// at most one non-terminal run holds it at a time.
111    ///
112    /// [`NewRun::concurrency_limits`] is validated before anything is written.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`StoreError::ConcurrencyConflict`](crate::error::StoreError::ConcurrencyConflict)
117    /// when a run that is not Completed, Failed, Warning or Cancelled already
118    /// holds [`NewRun::concurrency_key`],
119    /// [`StoreError::InvalidConcurrencyLimit`](crate::error::StoreError::InvalidConcurrencyLimit)
120    /// when [`NewRun::concurrency_limits`] holds an empty or too long group, a
121    /// zero limit or a duplicated group, and a database error when the backing
122    /// store fails.
123    fn create_run(&self, req: NewRun) -> StoreFuture<'_, RunCreation>;
124
125    /// Look up the run bound to an idempotency key.
126    ///
127    /// Returns `None` when the key is unknown, or when the run holding it is older
128    /// than [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW).
129    fn find_run_by_idempotency_key(&self, key: &str) -> StoreFuture<'_, Option<Run>>;
130
131    /// Get a run by ID. Returns `None` if not found.
132    fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>>;
133
134    /// List runs matching the given filter, with pagination.
135    ///
136    /// Results are ordered by `created_at` descending (newest first).
137    fn list_runs(&self, filter: RunFilter, page: u32, per_page: u32) -> StoreFuture<'_, Page<Run>>;
138
139    /// Update a run's status with FSM validation.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`StoreError::InvalidTransition`] if the transition is not allowed.
144    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
145    fn update_run_status(&self, id: Uuid, new_status: RunStatus) -> StoreFuture<'_, ()>;
146
147    /// Apply a partial update to a run.
148    ///
149    /// [`RunUpdate::lease`] is applied in the same transaction as the status
150    /// transition, after it: `status: Running` with
151    /// [`LeaseUpdate::Set`](crate::entities::LeaseUpdate::Set) leaves the run
152    /// `Running` and owned by that worker, so it is never `Running` without a
153    /// lease in between. [`LeaseUpdate::Release`](crate::entities::LeaseUpdate::Release)
154    /// drops the lease without touching the status. An explicit lease change
155    /// wins over the clearing that a transition out of `Running` does.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
160    fn update_run(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, ()>;
161
162    /// List the non-terminal descendants of a run, oldest first.
163    ///
164    /// A descendant is a sub-workflow run
165    /// ([`TriggerKind::Workflow`](crate::entities::TriggerKind::Workflow))
166    /// reached from `run_id` through
167    /// [`PARENT_RUN_ID_LABEL`](crate::entities::PARENT_RUN_ID_LABEL), at any
168    /// depth. Terminal runs are not returned, but their own descendants are:
169    /// a child left running under a finished parent is still found. Labels are
170    /// data, so a chain that loops back on itself is followed once and never
171    /// returns `run_id` itself.
172    ///
173    /// Returns an empty list for an unknown run or a run without children.
174    ///
175    /// # Errors
176    ///
177    /// Returns a database error when the backing store fails.
178    fn list_active_descendants(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Run>>;
179
180    /// Atomically pick the oldest pending run and transition it to `Running`.
181    ///
182    /// In PostgreSQL, this uses `SELECT FOR UPDATE SKIP LOCKED` for safe
183    /// multi-worker concurrency. The in-memory implementation uses a write lock.
184    ///
185    /// When `lease` is `Some`, the worker lease is attached in the same
186    /// transaction as the status change, so a run is never `Running` without an
187    /// owner. Pass `None` for callers that execute runs in-process and cannot
188    /// refresh a lease (inline execution, API-side resume): those runs are never
189    /// recovered by [`reap_expired_leases`](Self::reap_expired_leases).
190    ///
191    /// Concurrency groups gate the pick: a run carrying
192    /// [`Run::concurrency_limits`] is skipped while, for any of its groups, the
193    /// number of root runs in state `Running` carrying that group is already at
194    /// or above the run's own limit for it. Sleeping, awaiting approval,
195    /// retrying and pending runs do not count, and sub-workflow runs
196    /// ([`TriggerKind::Workflow`](crate::entities::TriggerKind::Workflow)) are
197    /// never counted. A held-back run does not block the queue: the oldest
198    /// eligible run wins. The check is atomic across concurrent callers, so a
199    /// group never exceeds its limit.
200    ///
201    /// Runs of a workflow paused with [`pause_workflow`](Self::pause_workflow)
202    /// are skipped the same way until the workflow is resumed.
203    ///
204    /// Returns `None` if no pending runs are available.
205    ///
206    /// Equivalent to [`pick_next_pending_for`](Self::pick_next_pending_for)
207    /// with no worker capabilities: every run is eligible.
208    fn pick_next_pending(&self, lease: Option<LeaseRequest>) -> StoreFuture<'_, Option<Run>> {
209        self.pick_next_pending_for(lease, None)
210    }
211
212    /// Atomically pick the oldest pending run the worker can take and
213    /// transition it to `Running`.
214    ///
215    /// Same contract as [`pick_next_pending`](Self::pick_next_pending), with
216    /// worker routing on top: when `capabilities` is `Some`, a run is only
217    /// eligible when [`WorkerCapabilities::can_take`] accepts its workflow name
218    /// and its [`Run::worker_tags`]. An ineligible run is skipped and never
219    /// blocks younger runs. `None` keeps the legacy behavior of a worker that
220    /// sends no capabilities: every run is eligible.
221    ///
222    /// Returns `None` if no eligible pending run is available.
223    fn pick_next_pending_for(
224        &self,
225        lease: Option<LeaseRequest>,
226        capabilities: Option<WorkerCapabilities>,
227    ) -> StoreFuture<'_, Option<Run>>;
228
229    /// Extend the worker lease on a run and return the new expiry.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
234    /// Returns [`StoreError::LeaseLost`] if the run is no longer `Running` or if
235    /// the lease belongs to another worker — the caller must stop executing it.
236    fn renew_lease(&self, id: Uuid, lease: LeaseRequest) -> StoreFuture<'_, DateTime<Utc>>;
237
238    /// Count, for each concurrency group, the due runs it currently holds back.
239    ///
240    /// A run is counted when it is pending or retrying, due (no
241    /// `scheduled_at` in the future) and not pickable because the group is
242    /// saturated for its own limit (see [`pick_next_pending`](Self::pick_next_pending)).
243    /// A run held back by two groups counts in both. Groups holding back no
244    /// run are omitted. Results are sorted by group name.
245    ///
246    /// # Errors
247    ///
248    /// Returns a database error when the backing store fails.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// use ironflow_store::store::RunStore;
254    ///
255    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
256    /// for backlog in store.count_blocked_runs_by_group().await? {
257    ///     println!("{}: {} runs held back", backlog.group, backlog.blocked_runs);
258    /// }
259    /// # Ok(())
260    /// # }
261    /// ```
262    fn count_blocked_runs_by_group(&self) -> StoreFuture<'_, Vec<ConcurrencyGroupBacklog>>;
263
264    /// Recover runs whose worker lease expired, at most `limit` per call.
265    ///
266    /// Each recovered run has [`Run::lease_recoveries`] incremented and its lease
267    /// cleared, then goes back to `Pending` — or to `Failed` with
268    /// [`LEASE_EXPIRED_ERROR`] once more than `max_retries` recoveries happened.
269    /// Runs without a lease are never touched.
270    /// A root run resumed through its sub-workflow child carries the lease the
271    /// child held (see [`RunUpdate::lease`]), so it is recovered like any run.
272    ///
273    /// [`Run::retry_count`], and so the attempt number of the steps created
274    /// afterwards, is left unchanged: a requeued run resumes in the same attempt
275    /// and replays the steps it already finished.
276    ///
277    /// The whole batch is atomic per run (`FOR UPDATE SKIP LOCKED` in
278    /// PostgreSQL), so concurrent reapers never recover the same run twice.
279    ///
280    /// Callers are responsible for the side effects that follow a recovery:
281    /// failing orphaned steps and publishing status-change events.
282    fn reap_expired_leases(&self, limit: u32) -> StoreFuture<'_, Vec<ReapedRun>>;
283
284    /// Atomically claim approval steps whose SLA deadline has passed.
285    ///
286    /// Returns the claimed steps with their *pre-claim* `approval_deadline_at`
287    /// still populated, so the caller can report which deadline fired. The
288    /// timer is cleared in the same transaction, so a deadline fires at most
289    /// once even with several API instances running the escalator (the
290    /// PostgreSQL implementation uses `FOR UPDATE SKIP LOCKED`).
291    ///
292    /// Only steps still in [`StepStatus::AwaitingApproval`](crate::entities::StepStatus::AwaitingApproval)
293    /// are returned.
294    ///
295    /// Delivery is at most once: a caller that crashes between the claim and
296    /// the escalation leaves the gate open with no timer, the same trade-off
297    /// [`reap_expired_leases`](Self::reap_expired_leases) accepts.
298    fn claim_due_approval_deadlines(&self, limit: u32) -> StoreFuture<'_, Vec<Step>>;
299
300    /// Atomically wake the `Sleeping` runs whose `scheduled_at` has passed, at
301    /// most `limit` per call.
302    ///
303    /// Each claimed run goes `Sleeping -> Pending` (`delay_elapsed`) and has
304    /// its `scheduled_at` cleared in the same transaction, so a run is woken
305    /// exactly once even with several API instances running the waker (the
306    /// PostgreSQL implementation uses `FOR UPDATE SKIP LOCKED`). Runs are
307    /// claimed oldest `scheduled_at` first.
308    ///
309    /// Returns the runs as they are after the transition. Callers decide how
310    /// the requeued runs resume: a worker picks them up, or the API resumes
311    /// them in-process when it has no worker.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`StoreError`] on storage failure.
316    fn claim_due_sleeping_runs(&self, limit: u32) -> StoreFuture<'_, Vec<Run>>;
317
318    /// Pause a workflow: its queued runs are no longer picked.
319    ///
320    /// Idempotent: pausing an already paused workflow keeps the original
321    /// [`WorkflowPause::paused_at`] and [`WorkflowPause::paused_by`] and
322    /// returns them. Runs already executing are not touched, and new runs are
323    /// still created: [`pick_next_pending`](Self::pick_next_pending) skips
324    /// them until [`resume_workflow`](Self::resume_workflow) is called.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`StoreError`] on storage failure.
329    ///
330    /// # Examples
331    ///
332    /// ```no_run
333    /// use ironflow_store::store::RunStore;
334    ///
335    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
336    /// let pause = store.pause_workflow("deploy", None).await?;
337    /// assert_eq!(pause.workflow_name, "deploy");
338    /// # Ok(())
339    /// # }
340    /// ```
341    fn pause_workflow(
342        &self,
343        workflow_name: &str,
344        paused_by: Option<Uuid>,
345    ) -> StoreFuture<'_, WorkflowPause>;
346
347    /// Resume a paused workflow so its queued runs are picked again.
348    ///
349    /// Returns `true` when the workflow was paused, `false` when it was not
350    /// (resuming is idempotent).
351    ///
352    /// # Errors
353    ///
354    /// Returns [`StoreError`] on storage failure.
355    ///
356    /// # Examples
357    ///
358    /// ```no_run
359    /// use ironflow_store::store::RunStore;
360    ///
361    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
362    /// let was_paused = store.resume_workflow("deploy").await?;
363    /// # Ok(())
364    /// # }
365    /// ```
366    fn resume_workflow(&self, workflow_name: &str) -> StoreFuture<'_, bool>;
367
368    /// List every paused workflow, ordered by workflow name.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`StoreError`] on storage failure.
373    ///
374    /// # Examples
375    ///
376    /// ```no_run
377    /// use ironflow_store::store::RunStore;
378    ///
379    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
380    /// for pause in store.list_workflow_pauses().await? {
381    ///     println!("{} paused at {}", pause.workflow_name, pause.paused_at);
382    /// }
383    /// # Ok(())
384    /// # }
385    /// ```
386    fn list_workflow_pauses(&self) -> StoreFuture<'_, Vec<WorkflowPause>>;
387
388    /// Create a new step for a run.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`StoreError::RunNotFound`] if the parent run does not exist.
393    fn create_step(&self, step: NewStep) -> StoreFuture<'_, Step>;
394
395    /// Apply a partial update to a step after execution.
396    ///
397    /// # Errors
398    ///
399    /// Returns [`StoreError::StepNotFound`] if the step does not exist.
400    fn update_step(&self, id: Uuid, update: StepUpdate) -> StoreFuture<'_, ()>;
401
402    /// Get a single step by ID. Returns `None` if not found.
403    fn get_step(&self, id: Uuid) -> StoreFuture<'_, Option<Step>>;
404
405    /// List all steps for a run, ordered by position ascending.
406    fn list_steps(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Step>>;
407
408    /// Record a vote on an approval gate and return the updated step.
409    ///
410    /// The vote is appended atomically to [`Step::approvals`] unless the same
411    /// [`StepApproval::user_id`] already voted, in which case the step is
412    /// returned unchanged. Recording a vote never resolves the gate: the
413    /// caller compares the vote count against the step's
414    /// [`approval_requirement`](Step::approval_requirement).
415    ///
416    /// # Errors
417    ///
418    /// Returns [`StoreError::StepNotFound`] if the step does not exist.
419    fn record_step_approval(&self, step_id: Uuid, approval: StepApproval) -> StoreFuture<'_, Step>;
420
421    /// Get aggregated statistics across runs matching the filter.
422    ///
423    /// Returns counts of runs by terminal state, counts of active runs
424    /// (`Pending`, `Running`, `Retrying`, `AwaitingApproval`, `Sleeping` or `Paused`),
425    /// the number of runs awaiting approval, and totals for cost and duration.
426    /// Computed efficiently by the store implementation (single SQL query in
427    /// PostgreSQL).
428    ///
429    /// Pass [`RunFilter::default()`] to get stats across all runs.
430    fn get_stats(&self, filter: RunFilter) -> StoreFuture<'_, RunStats>;
431
432    /// Get time-bucketed historical statistics for trend charts.
433    ///
434    /// Aggregates runs created during the filter's period into time buckets
435    /// based on its granularity, counting every run status and computing
436    /// duration percentiles. Applies the same run filters as
437    /// [`get_stats`](Self::get_stats) (workflow substring, status, labels,
438    /// steps, author). Bucket boundaries are UTC and weeks start on Monday
439    /// (see [`HistoryGranularity::bucket_start`](crate::entities::HistoryGranularity::bucket_start)).
440    /// Returns buckets ordered by time ascending; empty buckets are omitted.
441    ///
442    /// # Errors
443    ///
444    /// Returns [`StoreError::Database`] on underlying store failures.
445    ///
446    /// # Examples
447    ///
448    /// ```no_run
449    /// use ironflow_store::entities::{StatsHistoryFilter, HistoryPeriod, HistoryGranularity};
450    /// use ironflow_store::store::RunStore;
451    ///
452    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
453    /// let filter = StatsHistoryFilter {
454    ///     period: HistoryPeriod::SevenDays,
455    ///     granularity: HistoryGranularity::OneDay,
456    ///     ..StatsHistoryFilter::default()
457    /// };
458    /// let buckets = store.get_stats_history(filter).await?;
459    /// for b in &buckets {
460    ///     println!("{}: {} completed, {} failed", b.time, b.completed, b.failed);
461    /// }
462    /// # Ok(())
463    /// # }
464    /// ```
465    fn get_stats_history(
466        &self,
467        filter: StatsHistoryFilter,
468    ) -> StoreFuture<'_, Vec<StatsHistoryBucket>>;
469
470    /// List the agent steps recorded for one agent role, across every run and
471    /// workflow, newest first (`created_at DESC, id DESC`).
472    ///
473    /// Matches [`Step::agent_role`] exactly. `page` is 1-based. Steps stored
474    /// before roles were recorded have no role and are never listed.
475    ///
476    /// The default implementation returns [`StoreError::Database`]: a store
477    /// that cannot query steps by role does not support it.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`StoreError::Database`] on underlying store failures, or when
482    /// the store does not support the query.
483    ///
484    /// # Examples
485    ///
486    /// ```no_run
487    /// use ironflow_store::store::RunStore;
488    ///
489    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
490    /// let page = store.list_steps_by_agent_role("Reviewer", 1, 20).await?;
491    /// for step in &page.items {
492    ///     println!("{} in run {}: ${}", step.name, step.run_id, step.cost_usd);
493    /// }
494    /// # Ok(())
495    /// # }
496    /// ```
497    fn list_steps_by_agent_role<'a>(
498        &'a self,
499        agent_role: &'a str,
500        page: u32,
501        per_page: u32,
502    ) -> StoreFuture<'a, Page<Step>> {
503        let _ = (agent_role, page, per_page);
504        Box::pin(async move {
505            Err(StoreError::Database(
506                "list_steps_by_agent_role not supported by this store".to_string(),
507            ))
508        })
509    }
510
511    /// Aggregate the agent steps created at or after `created_after` by agent
512    /// role, all workflows combined.
513    ///
514    /// Only steps with a [`Step::agent_role`] count. `executions` is the number
515    /// of step records (each attempt counts), `failed` the steps in the
516    /// `Failed` state, and the cost and duration are sums. Sorted by
517    /// `total_cost_usd` descending, then by role name.
518    ///
519    /// The default implementation returns [`StoreError::Database`]: a store
520    /// that cannot aggregate steps by role does not support it.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`StoreError::Database`] on underlying store failures, or when
525    /// the store does not support the query.
526    ///
527    /// # Examples
528    ///
529    /// ```no_run
530    /// use chrono::{Duration, Utc};
531    /// use ironflow_store::store::RunStore;
532    ///
533    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
534    /// let roles = store.get_agent_role_stats(Utc::now() - Duration::days(7)).await?;
535    /// for role in &roles {
536    ///     println!("{}: {} runs, ${}", role.agent_role, role.executions, role.total_cost_usd);
537    /// }
538    /// # Ok(())
539    /// # }
540    /// ```
541    fn get_agent_role_stats(
542        &self,
543        created_after: DateTime<Utc>,
544    ) -> StoreFuture<'_, Vec<AgentRoleStats>> {
545        let _ = created_after;
546        Box::pin(async move {
547            Err(StoreError::Database(
548                "get_agent_role_stats not supported by this store".to_string(),
549            ))
550        })
551    }
552
553    /// Create step dependency edges in batch.
554    ///
555    /// Each entry records that `step_id` depends on `depends_on`.
556    /// Duplicate edges are silently ignored.
557    ///
558    /// # Errors
559    ///
560    /// Returns [`StoreError`] if a referenced step does not exist.
561    fn create_step_dependencies(&self, deps: Vec<NewStepDependency>) -> StoreFuture<'_, ()>;
562
563    /// List all step dependencies for a given run.
564    ///
565    /// Returns every edge where either `step_id` or `depends_on` belongs
566    /// to the run. Ordered by `created_at` ascending.
567    fn list_step_dependencies(&self, run_id: Uuid) -> StoreFuture<'_, Vec<StepDependency>>;
568
569    /// List runs eligible for purging according to the given policy.
570    ///
571    /// A run is eligible when it is in a terminal state ([`RunStatus::is_terminal`])
572    /// **and** either older than `policy.max_age_days` or exceeding
573    /// `policy.max_runs_per_workflow` for its workflow (oldest first).
574    ///
575    /// Runs in non-terminal states (`Pending`, `Running`, `Retrying`,
576    /// `AwaitingApproval`) are never returned.
577    ///
578    /// # Examples
579    ///
580    /// ```no_run
581    /// use ironflow_store::entities::PurgePolicy;
582    /// use ironflow_store::store::RunStore;
583    ///
584    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
585    /// let policy = PurgePolicy { max_age_days: 90, max_runs_per_workflow: 1000, dry_run: false };
586    /// let purgeable = store.list_purgeable_runs(&policy, 100).await?;
587    /// for p in &purgeable {
588    ///     println!("purge {} ({}): {}", p.run_id, p.workflow_name, p.reason);
589    /// }
590    /// # Ok(())
591    /// # }
592    /// ```
593    fn list_purgeable_runs(
594        &self,
595        policy: &PurgePolicy,
596        batch_size: u32,
597    ) -> StoreFuture<'_, Vec<PurgeableRun>>;
598
599    /// Delete a run and all its associated data (steps, step dependencies).
600    ///
601    /// Returns the `storage_key` of every artifact that belonged to the run,
602    /// so the caller can delete the corresponding blobs from the blob store.
603    ///
604    /// # Errors
605    ///
606    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
607    ///
608    /// # Examples
609    ///
610    /// ```no_run
611    /// use ironflow_store::store::RunStore;
612    /// use uuid::Uuid;
613    ///
614    /// # async fn example(store: &dyn RunStore, run_id: Uuid) -> Result<(), ironflow_store::error::StoreError> {
615    /// let storage_keys = store.delete_run(run_id).await?;
616    /// // Caller deletes blobs from the blob store using these keys.
617    /// # Ok(())
618    /// # }
619    /// ```
620    fn delete_run(&self, id: Uuid) -> StoreFuture<'_, Vec<String>>;
621
622    /// Apply a partial update to a run and return the updated run.
623    ///
624    /// Combines [`update_run`](Self::update_run) and [`get_run`](Self::get_run) in
625    /// a single operation to avoid an extra round-trip. Store implementations
626    /// may override this for efficiency (e.g. reading within the same transaction).
627    ///
628    /// The default implementation calls `update_run` followed by `get_run`.
629    ///
630    /// # Errors
631    ///
632    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
633    /// Returns [`StoreError::InvalidTransition`] if the status transition is not allowed.
634    fn update_run_returning(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, Run> {
635        Box::pin(async move {
636            self.update_run(id, update).await?;
637            self.get_run(id).await?.ok_or(StoreError::RunNotFound(id))
638        })
639    }
640}
641
642/// Unified storage abstraction combining all store capabilities.
643///
644/// Implementors provide runs, steps, users, API keys, and secrets
645/// through a single type. Pick one backend (in-memory or PostgreSQL)
646/// and it handles everything.
647///
648/// Both [`InMemoryStore`](crate::memory::InMemoryStore) and
649/// [`PostgresStore`](crate::postgres::PostgresStore) implement this trait.
650///
651/// # Examples
652///
653/// ```no_run
654/// use std::collections::HashMap;
655/// use std::sync::Arc;
656/// use ironflow_store::prelude::*;
657///
658/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
659/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
660///
661/// // All capabilities through one reference
662/// let _run = store.create_run(NewRun {
663///     workflow_name: "deploy".to_string(),
664///     trigger: TriggerKind::Manual,
665///     payload: serde_json::json!({}),
666///     max_retries: 3,
667///     handler_version: None,
668///     labels: HashMap::new(),
669///     scheduled_at: None,
670///     created_by: None,
671///     idempotency_key: None,
672///     concurrency_key: None,
673///     priority: 0,
674///     concurrency_limits: Vec::new(),
675///     max_cost_usd: None,
676///     worker_tags: Vec::new(),
677/// }).await?.into_run();
678/// let _users = store.count_users().await?;
679/// # Ok(())
680/// # }
681/// ```
682pub trait Store:
683    RunStore
684    + UserStore
685    + ApiKeyStore
686    + SecretStore
687    + AuditLogStore
688    + ArtifactStore
689    + LogStore
690    + ScheduleStore
691    + ApprovalDelegationStore
692    + ProviderAccountStore
693    + SignalStore
694    + AgentRoleStore
695{
696}
697
698impl<
699    T: RunStore
700        + UserStore
701        + ApiKeyStore
702        + SecretStore
703        + AuditLogStore
704        + ArtifactStore
705        + LogStore
706        + ScheduleStore
707        + ApprovalDelegationStore
708        + ProviderAccountStore
709        + SignalStore
710        + AgentRoleStore,
711> Store for T
712{
713}