Skip to main content

ironflow_store/memory/
run_store.rs

1use std::collections::{HashMap, HashSet};
2
3use chrono::{DateTime, Duration, Utc};
4use rust_decimal::Decimal;
5use uuid::Uuid;
6
7use crate::entities::{
8    ApiKey, IDEMPOTENCY_WINDOW, LeaseRequest, NewRun, NewStep, NewStepDependency, Page,
9    PurgePolicy, PurgeReason, PurgeableRun, ReapedRun, Run, RunActor, RunCreation, RunFilter,
10    RunStats, RunStatus, RunUpdate, StatsHistoryBucket, StatsHistoryFilter, Step, StepApproval,
11    StepDependency, StepStatus, StepUpdate, User,
12};
13use crate::error::StoreError;
14use crate::store::{LEASE_EXPIRED_ERROR, RunStore, StoreFuture};
15
16use super::stats_history::aggregate_history_buckets;
17use super::{InMemoryStore, State};
18
19/// Resolve [`Run::created_by_label`] from the current users and API keys.
20///
21/// Mirrors the `LEFT JOIN` the PostgreSQL store performs at read time, so a
22/// renamed API key or a renamed user is reflected immediately.
23fn resolve_created_by_label(
24    actor: Option<&RunActor>,
25    users: &HashMap<Uuid, User>,
26    api_keys: &HashMap<Uuid, ApiKey>,
27) -> Option<String> {
28    let actor = actor?;
29    let username = users.get(&actor.user_id()).map(|u| u.username.clone());
30
31    match actor.api_key_id() {
32        None => username,
33        Some(api_key_id) => {
34            let key_name = api_keys.get(&api_key_id).map(|k| k.name.clone())?;
35            Some(match username {
36                Some(username) => format!("{key_name} ({username})"),
37                None => key_name,
38            })
39        }
40    }
41}
42
43/// Return a clone of `run` with its display label resolved against `state`.
44fn run_with_label(run: &Run, state: &State) -> Run {
45    let mut run = run.clone();
46    run.created_by_label =
47        resolve_created_by_label(run.created_by.as_ref(), &state.users, &state.api_keys);
48    run
49}
50
51/// Drop the worker lease held on a run.
52fn clear_lease(run: &mut Run) {
53    run.worker_id = None;
54    run.lease_expires_at = None;
55}
56
57fn run_matches_filter(run: &Run, filter: &RunFilter, steps: &HashMap<Uuid, Step>) -> bool {
58    if let Some(ref wf) = filter.workflow_name
59        && !run
60            .workflow_name
61            .to_lowercase()
62            .contains(&wf.to_lowercase())
63    {
64        return false;
65    }
66    if let Some(ref status) = filter.status
67        && &run.status.state != status
68    {
69        return false;
70    }
71    if let Some(after) = filter.created_after
72        && run.created_at < after
73    {
74        return false;
75    }
76    if let Some(before) = filter.created_before
77        && run.created_at > before
78    {
79        return false;
80    }
81    if let Some(has_steps) = filter.has_steps
82        && matches!(
83            run.status.state,
84            RunStatus::Completed | RunStatus::Cancelled
85        )
86    {
87        let run_has_steps = steps.values().any(|s| s.run_id == run.id);
88        if has_steps != run_has_steps {
89            return false;
90        }
91    }
92    if let Some(ref labels) = filter.labels {
93        for (key, value) in labels {
94            if run.labels.get(key) != Some(value) {
95                return false;
96            }
97        }
98    }
99    if let Some(user_id) = filter.created_by_user_id
100        && run.created_by.as_ref().map(RunActor::user_id) != Some(user_id)
101    {
102        return false;
103    }
104    true
105}
106
107impl RunStore for InMemoryStore {
108    fn create_run(&self, req: NewRun) -> StoreFuture<'_, RunCreation> {
109        Box::pin(async move {
110            let now = Utc::now();
111
112            // Single critical section: the key lookup and the insert cannot be
113            // interleaved by a concurrent call sharing the same key.
114            let mut state = self.state.write().await;
115
116            if let Some(ref key) = req.idempotency_key
117                && let Some(existing) = state
118                    .idempotency_keys
119                    .get(key)
120                    .and_then(|id| state.runs.get(id))
121            {
122                if now - existing.created_at < IDEMPOTENCY_WINDOW {
123                    return Ok(RunCreation::Existing(run_with_label(existing, &state)));
124                }
125                // The key outlived its window: release it from the stale run.
126                let stale_id = existing.id;
127                state.idempotency_keys.remove(key);
128                if let Some(stale) = state.runs.get_mut(&stale_id) {
129                    stale.idempotency_key = None;
130                }
131            }
132
133            if let Some(ref key) = req.concurrency_key
134                && let Some(holder) = state
135                    .runs
136                    .values()
137                    .filter(|r| {
138                        r.concurrency_key.as_deref() == Some(key.as_str())
139                            && !r.status.state.is_terminal()
140                    })
141                    .min_by_key(|r| r.created_at)
142            {
143                return Err(StoreError::ConcurrencyConflict {
144                    key: key.clone(),
145                    run_id: holder.id,
146                });
147            }
148
149            let run = Run {
150                id: Uuid::now_v7(),
151                workflow_name: req.workflow_name,
152                status: crate::entities::FsmState::new(RunStatus::Pending, Uuid::now_v7()),
153                trigger: req.trigger,
154                payload: req.payload,
155                error: None,
156                retry_count: 0,
157                max_retries: req.max_retries,
158                cost_usd: Decimal::ZERO,
159                duration_ms: 0,
160                created_at: now,
161                updated_at: now,
162                started_at: None,
163                completed_at: None,
164                handler_version: req.handler_version,
165                labels: req.labels,
166                scheduled_at: req.scheduled_at,
167                created_by: req.created_by,
168                created_by_label: None,
169                idempotency_key: req.idempotency_key.clone(),
170                concurrency_key: req.concurrency_key,
171                max_cost_usd: req.max_cost_usd,
172                worker_id: None,
173                lease_expires_at: None,
174                output: None,
175                lease_recoveries: 0,
176            };
177
178            if let Some(key) = req.idempotency_key {
179                state.idempotency_keys.insert(key, run.id);
180            }
181            state.runs.insert(run.id, run.clone());
182            Ok(RunCreation::Created(run_with_label(&run, &state)))
183        })
184    }
185
186    fn find_run_by_idempotency_key(&self, key: &str) -> StoreFuture<'_, Option<Run>> {
187        let key = key.to_string();
188        Box::pin(async move {
189            let now = Utc::now();
190            let state = self.state.read().await;
191            Ok(state
192                .idempotency_keys
193                .get(&key)
194                .and_then(|id| state.runs.get(id))
195                .filter(|run| now - run.created_at < IDEMPOTENCY_WINDOW)
196                .map(|run| run_with_label(run, &state)))
197        })
198    }
199
200    fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>> {
201        Box::pin(async move {
202            let state = self.state.read().await;
203            Ok(state.runs.get(&id).map(|r| run_with_label(r, &state)))
204        })
205    }
206
207    fn list_runs(&self, filter: RunFilter, page: u32, per_page: u32) -> StoreFuture<'_, Page<Run>> {
208        Box::pin(async move {
209            let state = self.state.read().await;
210
211            let mut runs: Vec<&Run> = state
212                .runs
213                .values()
214                .filter(|r| run_matches_filter(r, &filter, &state.steps))
215                .collect();
216
217            // Sort newest first.
218            runs.sort_by_key(|r| std::cmp::Reverse(r.created_at));
219
220            let total = runs.len() as u64;
221            let page = page.max(1);
222            let per_page = per_page.clamp(1, 100);
223            let offset = ((page - 1) * per_page) as usize;
224            let items: Vec<Run> = runs
225                .into_iter()
226                .skip(offset)
227                .take(per_page as usize)
228                .map(|r| run_with_label(r, &state))
229                .collect();
230
231            Ok(Page {
232                items,
233                total,
234                page,
235                per_page,
236            })
237        })
238    }
239
240    fn update_run_status(&self, id: Uuid, new_status: RunStatus) -> StoreFuture<'_, ()> {
241        Box::pin(async move {
242            let mut state = self.state.write().await;
243            let run = state.runs.get_mut(&id).ok_or(StoreError::RunNotFound(id))?;
244
245            if !run.status.state.can_transition_to(&new_status) {
246                return Err(StoreError::InvalidTransition {
247                    from: run.status.state,
248                    to: new_status,
249                });
250            }
251
252            if run.status.state == new_status && new_status.is_terminal() {
253                return Ok(());
254            }
255
256            let now = Utc::now();
257            run.status.state = new_status;
258            run.updated_at = now;
259
260            if new_status == RunStatus::Running && run.started_at.is_none() {
261                run.started_at = Some(now);
262            }
263            if new_status.is_terminal() {
264                run.completed_at = Some(now);
265            }
266            if new_status != RunStatus::Running {
267                clear_lease(run);
268            }
269
270            Ok(())
271        })
272    }
273
274    fn update_run(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, ()> {
275        Box::pin(async move {
276            let mut state = self.state.write().await;
277            let run = state.runs.get_mut(&id).ok_or(StoreError::RunNotFound(id))?;
278
279            let now = Utc::now();
280
281            if let Some(status) = update.status {
282                if !run.status.state.can_transition_to(&status) {
283                    return Err(StoreError::InvalidTransition {
284                        from: run.status.state,
285                        to: status,
286                    });
287                }
288                if !(run.status.state == status && status.is_terminal()) {
289                    run.status.state = status;
290                    if status == RunStatus::Running && run.started_at.is_none() {
291                        run.started_at = Some(now);
292                    }
293                    if status.is_terminal() {
294                        run.completed_at = Some(now);
295                    }
296                    if status != RunStatus::Running {
297                        clear_lease(run);
298                    }
299                }
300            }
301
302            if let Some(error) = update.error {
303                run.error = Some(error);
304            }
305            if update.increment_retry {
306                run.retry_count += 1;
307            }
308            if let Some(cost) = update.cost_usd {
309                run.cost_usd = cost;
310            }
311            if let Some(dur) = update.duration_ms {
312                run.duration_ms = dur;
313            }
314            if let Some(started) = update.started_at {
315                run.started_at = Some(started);
316            }
317            if let Some(completed) = update.completed_at {
318                run.completed_at = Some(completed);
319            }
320            if let Some(scheduled) = update.scheduled_at {
321                run.scheduled_at = Some(scheduled);
322            }
323            if let Some(output) = update.output {
324                run.output = Some(output);
325            }
326
327            run.updated_at = now;
328            Ok(())
329        })
330    }
331
332    fn pick_next_pending(&self, lease: Option<LeaseRequest>) -> StoreFuture<'_, Option<Run>> {
333        Box::pin(async move {
334            let mut state = self.state.write().await;
335            let now = Utc::now();
336
337            // Find the oldest run waiting for execution whose scheduled_at has
338            // passed (or is None). `Retrying` runs are runs whose automatic retry
339            // backoff has been armed: they become eligible again once
340            // `scheduled_at` has passed.
341            let oldest_id = state
342                .runs
343                .values()
344                .filter(|r| {
345                    matches!(r.status.state, RunStatus::Pending | RunStatus::Retrying)
346                        && r.scheduled_at.is_none_or(|at| at <= now)
347                })
348                .min_by_key(|r| r.created_at)
349                .map(|r| r.id);
350
351            let Some(id) = oldest_id else {
352                return Ok(None);
353            };
354
355            // Transition to Running and attach the lease atomically.
356            let run = state.runs.get_mut(&id).expect("run exists");
357            let now = Utc::now();
358            run.status.state = RunStatus::Running;
359            run.started_at = Some(now);
360            run.updated_at = now;
361            match lease {
362                Some(lease) => {
363                    run.lease_expires_at = Some(lease.expires_at(now));
364                    run.worker_id = Some(lease.worker_id);
365                }
366                None => clear_lease(run),
367            }
368            let run = run.clone();
369
370            Ok(Some(run_with_label(&run, &state)))
371        })
372    }
373
374    fn renew_lease(&self, id: Uuid, lease: LeaseRequest) -> StoreFuture<'_, DateTime<Utc>> {
375        Box::pin(async move {
376            let mut state = self.state.write().await;
377            let run = state.runs.get_mut(&id).ok_or(StoreError::RunNotFound(id))?;
378
379            if run.status.state != RunStatus::Running
380                || run.worker_id.as_deref() != Some(lease.worker_id.as_str())
381            {
382                return Err(StoreError::LeaseLost {
383                    run_id: id,
384                    held_by: run.worker_id.clone(),
385                });
386            }
387
388            let now = Utc::now();
389            let expires_at = lease.expires_at(now);
390            run.lease_expires_at = Some(expires_at);
391            run.updated_at = now;
392
393            Ok(expires_at)
394        })
395    }
396
397    fn reap_expired_leases(&self, limit: u32) -> StoreFuture<'_, Vec<ReapedRun>> {
398        Box::pin(async move {
399            let mut state = self.state.write().await;
400            let now = Utc::now();
401
402            let mut expired: Vec<Uuid> = state
403                .runs
404                .values()
405                .filter(|r| {
406                    r.status.state == RunStatus::Running
407                        && r.lease_expires_at.is_some_and(|at| at < now)
408                })
409                .map(|r| r.id)
410                .collect();
411            expired.sort_unstable();
412            expired.truncate(limit as usize);
413
414            let mut reaped = Vec::with_capacity(expired.len());
415            for id in expired {
416                let run = state.runs.get_mut(&id).expect("run exists");
417                run.lease_recoveries += 1;
418                clear_lease(run);
419                run.updated_at = now;
420
421                let to = if run.lease_recoveries > run.max_retries {
422                    run.status.state = RunStatus::Failed;
423                    run.error = Some(LEASE_EXPIRED_ERROR.to_string());
424                    run.completed_at = Some(now);
425                    RunStatus::Failed
426                } else {
427                    run.status.state = RunStatus::Pending;
428                    RunStatus::Pending
429                };
430
431                reaped.push(ReapedRun {
432                    run: run.clone(),
433                    from: RunStatus::Running,
434                    to,
435                });
436            }
437
438            Ok(reaped)
439        })
440    }
441
442    fn claim_due_approval_deadlines(&self, limit: u32) -> StoreFuture<'_, Vec<Step>> {
443        Box::pin(async move {
444            let mut state = self.state.write().await;
445            let now = Utc::now();
446
447            let mut due: Vec<(DateTime<Utc>, Uuid)> = state
448                .steps
449                .values()
450                .filter(|s| {
451                    s.status.state == StepStatus::AwaitingApproval
452                        && s.approval_deadline_at.is_some_and(|at| at <= now)
453                })
454                .map(|s| (s.approval_deadline_at.expect("deadline is set"), s.id))
455                .collect();
456            due.sort_unstable();
457            due.truncate(limit as usize);
458
459            let mut claimed = Vec::with_capacity(due.len());
460            for (_, id) in due {
461                let step = state.steps.get_mut(&id).expect("step exists");
462                // Clone before clearing so the caller still sees the deadline
463                // that fired.
464                claimed.push(step.clone());
465                step.approval_deadline_at = None;
466                step.updated_at = now;
467            }
468
469            Ok(claimed)
470        })
471    }
472
473    fn claim_due_sleeping_runs(&self, limit: u32) -> StoreFuture<'_, Vec<Run>> {
474        Box::pin(async move {
475            let mut state = self.state.write().await;
476            let now = Utc::now();
477
478            let mut due: Vec<(DateTime<Utc>, Uuid)> = state
479                .runs
480                .values()
481                .filter(|r| r.status.state == RunStatus::Sleeping)
482                .filter_map(|r| r.scheduled_at.filter(|at| *at <= now).map(|at| (at, r.id)))
483                .collect();
484            due.sort_unstable();
485            due.truncate(limit as usize);
486
487            let mut woken = Vec::with_capacity(due.len());
488            for (_, id) in due {
489                let run = state.runs.get_mut(&id).expect("run exists");
490                run.status.state = RunStatus::Pending;
491                run.scheduled_at = None;
492                run.updated_at = now;
493                let run = run.clone();
494                woken.push(run_with_label(&run, &state));
495            }
496
497            Ok(woken)
498        })
499    }
500
501    fn list_purgeable_runs(
502        &self,
503        policy: &PurgePolicy,
504        batch_size: u32,
505    ) -> StoreFuture<'_, Vec<PurgeableRun>> {
506        let max_age_days = policy.max_age_days;
507        let max_runs_per_workflow = policy.max_runs_per_workflow;
508        Box::pin(async move {
509            let state = self.state.read().await;
510            let cutoff = Utc::now() - Duration::days(i64::from(max_age_days));
511            let mut result: Vec<PurgeableRun> = Vec::new();
512            let mut seen: HashSet<Uuid> = HashSet::new();
513
514            for run in state.runs.values() {
515                if run.status.state.is_terminal() && run.created_at < cutoff {
516                    seen.insert(run.id);
517                    result.push(PurgeableRun {
518                        run_id: run.id,
519                        workflow_name: run.workflow_name.clone(),
520                        reason: PurgeReason::TooOld,
521                    });
522                }
523            }
524
525            let mut by_workflow: HashMap<&str, Vec<&Run>> = HashMap::new();
526            for run in state.runs.values() {
527                if run.status.state.is_terminal() {
528                    by_workflow.entry(&run.workflow_name).or_default().push(run);
529                }
530            }
531            for (_, mut runs) in by_workflow {
532                if runs.len() > max_runs_per_workflow as usize {
533                    runs.sort_by_key(|r| r.created_at);
534                    let excess = runs.len() - max_runs_per_workflow as usize;
535                    for run in runs.into_iter().take(excess) {
536                        if seen.insert(run.id) {
537                            result.push(PurgeableRun {
538                                run_id: run.id,
539                                workflow_name: run.workflow_name.clone(),
540                                reason: PurgeReason::ExceedsWorkflowLimit,
541                            });
542                        }
543                    }
544                }
545            }
546
547            result.sort_by_key(|p| p.run_id);
548            result.truncate(batch_size as usize);
549            Ok(result)
550        })
551    }
552
553    fn delete_run(&self, id: Uuid) -> StoreFuture<'_, Vec<String>> {
554        Box::pin(async move {
555            let mut state = self.state.write().await;
556
557            if !state.runs.contains_key(&id) {
558                return Err(StoreError::RunNotFound(id));
559            }
560
561            // Collect artifact storage keys before removing them.
562            let storage_keys: Vec<String> = state
563                .artifacts
564                .values()
565                .filter(|a| a.run_id == id)
566                .map(|a| a.storage_key.clone())
567                .collect();
568
569            // Remove artifacts.
570            state.artifacts.retain(|_, a| a.run_id != id);
571
572            // Remove step dependencies (both sides).
573            let step_ids: Vec<Uuid> = state
574                .steps
575                .values()
576                .filter(|s| s.run_id == id)
577                .map(|s| s.id)
578                .collect();
579            state
580                .step_dependencies
581                .retain(|d| !step_ids.contains(&d.step_id) && !step_ids.contains(&d.depends_on));
582
583            // Remove steps.
584            state.steps.retain(|_, s| s.run_id != id);
585
586            // Remove idempotency key pointing to this run.
587            state.idempotency_keys.retain(|_, &mut run_id| run_id != id);
588
589            // Remove the run itself.
590            state.runs.remove(&id);
591
592            Ok(storage_keys)
593        })
594    }
595
596    fn create_step(&self, req: NewStep) -> StoreFuture<'_, Step> {
597        Box::pin(async move {
598            let mut state = self.state.write().await;
599
600            let attempt = state
601                .runs
602                .get(&req.run_id)
603                .ok_or(StoreError::RunNotFound(req.run_id))?
604                .retry_count
605                + 1;
606
607            let now = Utc::now();
608            let step = Step {
609                id: Uuid::now_v7(),
610                trace_id: req.trace_id,
611                run_id: req.run_id,
612                name: req.name,
613                kind: req.kind,
614                position: req.position,
615                status: crate::entities::FsmState::new(StepStatus::Pending, Uuid::now_v7()),
616                attempt,
617                input: req.input,
618                output: None,
619                error: None,
620                duration_ms: 0,
621                cost_usd: Decimal::ZERO,
622                input_tokens: None,
623                cache_read_input_tokens: None,
624                cache_creation_input_tokens: None,
625                output_tokens: None,
626                created_at: now,
627                updated_at: now,
628                started_at: None,
629                completed_at: None,
630                debug_messages: None,
631                is_error_handler: req.is_error_handler,
632                approval_deadline_at: None,
633                approval_stage: 0,
634                approval_assignee: None,
635                approval_requirement: None,
636                approvals: Vec::new(),
637                account_id: None,
638                environment_id: None,
639            };
640
641            state.steps.insert(step.id, step.clone());
642            Ok(step)
643        })
644    }
645
646    fn update_step(&self, id: Uuid, update: StepUpdate) -> StoreFuture<'_, ()> {
647        Box::pin(async move {
648            let mut state = self.state.write().await;
649            let step = state
650                .steps
651                .get_mut(&id)
652                .ok_or(StoreError::StepNotFound(id))?;
653
654            let now = Utc::now();
655
656            if let Some(status) = update.status {
657                if !matches!(
658                    (step.status.state, status),
659                    (StepStatus::Pending, StepStatus::Running)
660                        | (StepStatus::Pending, StepStatus::Skipped)
661                        | (StepStatus::Running, StepStatus::Completed)
662                        | (StepStatus::Running, StepStatus::Failed)
663                        | (StepStatus::Running, StepStatus::AwaitingApproval)
664                        | (StepStatus::AwaitingApproval, StepStatus::Running)
665                        | (StepStatus::AwaitingApproval, StepStatus::Completed)
666                        | (StepStatus::AwaitingApproval, StepStatus::Failed)
667                        | (StepStatus::AwaitingApproval, StepStatus::Rejected)
668                ) {
669                    return Err(StoreError::Database(format!(
670                        "invalid step status transition: {:?} -> {:?}",
671                        step.status.state, status
672                    )));
673                }
674                step.status.state = status;
675            }
676            if let Some(output) = update.output {
677                step.output = Some(output);
678            }
679            if let Some(error) = update.error {
680                step.error = Some(error);
681            }
682            if let Some(dur) = update.duration_ms {
683                step.duration_ms = dur;
684            }
685            if let Some(cost) = update.cost_usd {
686                step.cost_usd = cost;
687            }
688            if let Some(tokens) = update.input_tokens {
689                step.input_tokens = Some(tokens);
690            }
691            if let Some(tokens) = update.cache_read_input_tokens {
692                step.cache_read_input_tokens = Some(tokens);
693            }
694            if let Some(tokens) = update.cache_creation_input_tokens {
695                step.cache_creation_input_tokens = Some(tokens);
696            }
697            if let Some(account_id) = update.account_id {
698                step.account_id = Some(account_id);
699            }
700            if let Some(environment_id) = update.environment_id {
701                step.environment_id = Some(environment_id);
702            }
703            if let Some(tokens) = update.output_tokens {
704                step.output_tokens = Some(tokens);
705            }
706            if let Some(started) = update.started_at {
707                step.started_at = Some(started);
708            }
709            if let Some(completed) = update.completed_at {
710                step.completed_at = Some(completed);
711            }
712            if let Some(debug_msgs) = update.debug_messages {
713                step.debug_messages = Some(debug_msgs);
714            }
715            // Clearing wins over setting: an update that resolves the gate and
716            // reschedules at once would otherwise leave a live timer behind.
717            if update.clear_approval_deadline {
718                step.approval_deadline_at = None;
719            } else if let Some(deadline) = update.approval_deadline_at {
720                step.approval_deadline_at = Some(deadline);
721            }
722            if let Some(stage) = update.approval_stage {
723                step.approval_stage = stage;
724            }
725            if let Some(assignee) = update.approval_assignee {
726                step.approval_assignee = Some(assignee);
727            }
728            if let Some(requirement) = update.approval_requirement {
729                step.approval_requirement = Some(requirement);
730            }
731
732            step.updated_at = now;
733            Ok(())
734        })
735    }
736
737    fn get_step(&self, id: Uuid) -> StoreFuture<'_, Option<Step>> {
738        Box::pin(async move {
739            let state = self.state.read().await;
740            Ok(state.steps.get(&id).cloned())
741        })
742    }
743
744    fn record_step_approval(&self, step_id: Uuid, approval: StepApproval) -> StoreFuture<'_, Step> {
745        Box::pin(async move {
746            let mut state = self.state.write().await;
747            let step = state
748                .steps
749                .get_mut(&step_id)
750                .ok_or(StoreError::StepNotFound(step_id))?;
751
752            if !step.approvals.iter().any(|a| a.user_id == approval.user_id) {
753                step.approvals.push(approval);
754                step.updated_at = Utc::now();
755            }
756            Ok(step.clone())
757        })
758    }
759
760    fn list_steps(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Step>> {
761        Box::pin(async move {
762            let state = self.state.read().await;
763            let mut steps: Vec<Step> = state
764                .steps
765                .values()
766                .filter(|s| s.run_id == run_id)
767                .cloned()
768                .collect();
769            steps.sort_by_key(|s| s.position);
770            Ok(steps)
771        })
772    }
773
774    fn get_stats(&self, filter: RunFilter) -> StoreFuture<'_, RunStats> {
775        Box::pin(async move {
776            let state = self.state.read().await;
777
778            let mut total_cost_usd = Decimal::ZERO;
779            let mut total_duration_ms = 0u64;
780            let mut total_runs = 0u64;
781            let mut completed_runs = 0u64;
782            let mut failed_runs = 0u64;
783            let mut cancelled_runs = 0u64;
784            let mut active_runs = 0u64;
785            let mut awaiting_approval_runs = 0u64;
786
787            for run in state.runs.values() {
788                if !run_matches_filter(run, &filter, &state.steps) {
789                    continue;
790                }
791
792                total_cost_usd += run.cost_usd;
793                total_duration_ms += run.duration_ms;
794                total_runs += 1;
795
796                match run.status.state {
797                    RunStatus::Completed | RunStatus::Warning => completed_runs += 1,
798                    RunStatus::Failed => failed_runs += 1,
799                    RunStatus::Cancelled => cancelled_runs += 1,
800                    RunStatus::AwaitingApproval => {
801                        active_runs += 1;
802                        awaiting_approval_runs += 1;
803                    }
804                    RunStatus::Pending
805                    | RunStatus::Running
806                    | RunStatus::Retrying
807                    | RunStatus::Sleeping => {
808                        active_runs += 1;
809                    }
810                }
811            }
812
813            Ok(RunStats {
814                total_runs,
815                completed_runs,
816                failed_runs,
817                cancelled_runs,
818                active_runs,
819                awaiting_approval_runs,
820                total_cost_usd,
821                total_duration_ms,
822            })
823        })
824    }
825
826    fn get_stats_history(
827        &self,
828        filter: StatsHistoryFilter,
829    ) -> StoreFuture<'_, Vec<StatsHistoryBucket>> {
830        Box::pin(async move {
831            let state = self.state.read().await;
832            let now = Utc::now();
833            let start = now - Duration::hours(filter.period.hours());
834            let run_filter = filter.to_run_filter();
835            let buckets = aggregate_history_buckets(
836                state
837                    .runs
838                    .values()
839                    .filter(|r| run_matches_filter(r, &run_filter, &state.steps)),
840                start,
841                now,
842                filter.granularity,
843            );
844            Ok(buckets)
845        })
846    }
847
848    fn create_step_dependencies(&self, deps: Vec<NewStepDependency>) -> StoreFuture<'_, ()> {
849        Box::pin(async move {
850            let mut state = self.state.write().await;
851
852            for dep in deps {
853                if !state.steps.contains_key(&dep.step_id) {
854                    return Err(StoreError::StepNotFound(dep.step_id));
855                }
856                if !state.steps.contains_key(&dep.depends_on) {
857                    return Err(StoreError::StepNotFound(dep.depends_on));
858                }
859
860                let already_exists = state
861                    .step_dependencies
862                    .iter()
863                    .any(|d| d.step_id == dep.step_id && d.depends_on == dep.depends_on);
864
865                if !already_exists {
866                    state.step_dependencies.push(StepDependency {
867                        step_id: dep.step_id,
868                        depends_on: dep.depends_on,
869                        created_at: Utc::now(),
870                    });
871                }
872            }
873
874            Ok(())
875        })
876    }
877
878    fn list_step_dependencies(&self, run_id: Uuid) -> StoreFuture<'_, Vec<StepDependency>> {
879        Box::pin(async move {
880            let state = self.state.read().await;
881
882            let run_step_ids: std::collections::HashSet<Uuid> = state
883                .steps
884                .values()
885                .filter(|s| s.run_id == run_id)
886                .map(|s| s.id)
887                .collect();
888
889            let mut deps: Vec<StepDependency> = state
890                .step_dependencies
891                .iter()
892                .filter(|d| run_step_ids.contains(&d.step_id))
893                .cloned()
894                .collect();
895
896            deps.sort_by_key(|d| d.created_at);
897            Ok(deps)
898        })
899    }
900}
901
902#[cfg(test)]
903mod tests {
904    use std::collections::HashMap;
905    use std::time::Duration;
906
907    use chrono::TimeDelta;
908    use serde_json::json;
909    use tokio::spawn;
910    use tokio::time::sleep;
911
912    use super::*;
913    use crate::api_key_store::ApiKeyStore;
914    use crate::entities::{
915        ApiKeyScope, ApiKeyUpdate, ApprovalRequirement, NewApiKey, NewUser, TriggerKind,
916    };
917    use crate::user_store::UserStore;
918
919    use crate::memory::tests::{create_terminal_run, new_run_req};
920    use crate::store::RunStore;
921
922    use crate::entities::{StepKind, step_trace_id};
923
924    fn new_step_req(run_id: Uuid, name: &str, position: u32) -> NewStep {
925        NewStep {
926            run_id,
927            trace_id: step_trace_id(run_id, name, position),
928            name: name.to_string(),
929            kind: StepKind::Shell,
930            position,
931            input: None,
932            is_error_handler: false,
933        }
934    }
935
936    // ---- create_run ----
937
938    #[tokio::test]
939    async fn create_run_returns_pending_status() {
940        let store = InMemoryStore::new();
941        let run = store
942            .create_run(new_run_req("test"))
943            .await
944            .unwrap()
945            .into_run();
946        assert_eq!(run.status.state, RunStatus::Pending);
947        assert_eq!(run.workflow_name, "test");
948        assert_eq!(run.retry_count, 0);
949        assert_eq!(run.max_retries, 3);
950    }
951
952    #[tokio::test]
953    async fn create_run_generates_unique_ids() {
954        let store = InMemoryStore::new();
955        let r1 = store.create_run(new_run_req("a")).await.unwrap().into_run();
956        let r2 = store.create_run(new_run_req("b")).await.unwrap().into_run();
957        assert_ne!(r1.id, r2.id);
958    }
959
960    // ---- get_run ----
961
962    #[tokio::test]
963    async fn get_run_returns_created_run() {
964        let store = InMemoryStore::new();
965        let run = store
966            .create_run(new_run_req("test"))
967            .await
968            .unwrap()
969            .into_run();
970        let fetched = store.get_run(run.id).await.unwrap();
971        assert!(fetched.is_some());
972        assert_eq!(fetched.unwrap().id, run.id);
973    }
974
975    #[tokio::test]
976    async fn get_run_returns_none_for_missing() {
977        let store = InMemoryStore::new();
978        let fetched = store.get_run(Uuid::nil()).await.unwrap();
979        assert!(fetched.is_none());
980    }
981
982    // ---- update_run_status ----
983
984    #[tokio::test]
985    async fn update_run_status_valid_transition() {
986        let store = InMemoryStore::new();
987        let run = store
988            .create_run(new_run_req("test"))
989            .await
990            .unwrap()
991            .into_run();
992
993        store
994            .update_run_status(run.id, RunStatus::Running)
995            .await
996            .unwrap();
997
998        let fetched = store.get_run(run.id).await.unwrap().unwrap();
999        assert_eq!(fetched.status.state, RunStatus::Running);
1000        assert!(fetched.started_at.is_some());
1001    }
1002
1003    #[tokio::test]
1004    async fn update_run_status_invalid_transition_returns_error() {
1005        let store = InMemoryStore::new();
1006        let run = store
1007            .create_run(new_run_req("test"))
1008            .await
1009            .unwrap()
1010            .into_run();
1011
1012        let result = store.update_run_status(run.id, RunStatus::Completed).await;
1013        assert!(result.is_err());
1014
1015        let err = result.unwrap_err();
1016        assert!(matches!(err, StoreError::InvalidTransition { .. }));
1017    }
1018
1019    #[tokio::test]
1020    async fn update_run_status_not_found() {
1021        let store = InMemoryStore::new();
1022        let result = store
1023            .update_run_status(Uuid::nil(), RunStatus::Running)
1024            .await;
1025        assert!(matches!(result.unwrap_err(), StoreError::RunNotFound(_)));
1026    }
1027
1028    #[tokio::test]
1029    async fn update_run_status_terminal_sets_completed_at() {
1030        let store = InMemoryStore::new();
1031        let run = store
1032            .create_run(new_run_req("test"))
1033            .await
1034            .unwrap()
1035            .into_run();
1036
1037        store
1038            .update_run_status(run.id, RunStatus::Running)
1039            .await
1040            .unwrap();
1041        store
1042            .update_run_status(run.id, RunStatus::Completed)
1043            .await
1044            .unwrap();
1045
1046        let fetched = store.get_run(run.id).await.unwrap().unwrap();
1047        assert_eq!(fetched.status.state, RunStatus::Completed);
1048        assert!(fetched.completed_at.is_some());
1049    }
1050
1051    #[tokio::test]
1052    async fn update_run_status_terminal_to_same_is_idempotent() {
1053        let store = InMemoryStore::new();
1054        let run = store
1055            .create_run(new_run_req("test"))
1056            .await
1057            .unwrap()
1058            .into_run();
1059
1060        store
1061            .update_run_status(run.id, RunStatus::Running)
1062            .await
1063            .unwrap();
1064        store
1065            .update_run_status(run.id, RunStatus::Failed)
1066            .await
1067            .unwrap();
1068
1069        let before = store.get_run(run.id).await.unwrap().unwrap();
1070        let completed_at_before = before.completed_at;
1071
1072        store
1073            .update_run_status(run.id, RunStatus::Failed)
1074            .await
1075            .unwrap();
1076
1077        let after = store.get_run(run.id).await.unwrap().unwrap();
1078        assert_eq!(after.status.state, RunStatus::Failed);
1079        assert_eq!(after.completed_at, completed_at_before);
1080    }
1081
1082    #[tokio::test]
1083    async fn update_run_terminal_to_same_via_update_run_is_idempotent() {
1084        let store = InMemoryStore::new();
1085        let run = store
1086            .create_run(new_run_req("test"))
1087            .await
1088            .unwrap()
1089            .into_run();
1090
1091        store
1092            .update_run_status(run.id, RunStatus::Running)
1093            .await
1094            .unwrap();
1095        store
1096            .update_run(
1097                run.id,
1098                RunUpdate {
1099                    status: Some(RunStatus::Failed),
1100                    error: Some("first failure".to_string()),
1101                    ..RunUpdate::default()
1102                },
1103            )
1104            .await
1105            .unwrap();
1106
1107        let before = store.get_run(run.id).await.unwrap().unwrap();
1108
1109        store
1110            .update_run(
1111                run.id,
1112                RunUpdate {
1113                    status: Some(RunStatus::Failed),
1114                    ..RunUpdate::default()
1115                },
1116            )
1117            .await
1118            .unwrap();
1119
1120        let after = store.get_run(run.id).await.unwrap().unwrap();
1121        assert_eq!(after.status.state, RunStatus::Failed);
1122        assert_eq!(after.completed_at, before.completed_at);
1123        assert_eq!(after.error, Some("first failure".to_string()));
1124    }
1125
1126    // ---- list_runs ----
1127
1128    #[tokio::test]
1129    async fn list_runs_empty_store() {
1130        let store = InMemoryStore::new();
1131        let page = store.list_runs(RunFilter::default(), 1, 20).await.unwrap();
1132        assert_eq!(page.total, 0);
1133        assert!(page.items.is_empty());
1134    }
1135
1136    #[tokio::test]
1137    async fn list_runs_with_workflow_filter() {
1138        let store = InMemoryStore::new();
1139        store
1140            .create_run(new_run_req("deploy"))
1141            .await
1142            .unwrap()
1143            .into_run();
1144        store
1145            .create_run(new_run_req("test"))
1146            .await
1147            .unwrap()
1148            .into_run();
1149        store
1150            .create_run(new_run_req("deploy"))
1151            .await
1152            .unwrap()
1153            .into_run();
1154
1155        let filter = RunFilter {
1156            workflow_name: Some("deploy".to_string()),
1157            ..RunFilter::default()
1158        };
1159        let page = store.list_runs(filter, 1, 20).await.unwrap();
1160        assert_eq!(page.total, 2);
1161        assert!(page.items.iter().all(|r| r.workflow_name == "deploy"));
1162    }
1163
1164    #[tokio::test]
1165    async fn list_runs_with_status_filter() {
1166        let store = InMemoryStore::new();
1167        let run = store.create_run(new_run_req("a")).await.unwrap().into_run();
1168        store.create_run(new_run_req("b")).await.unwrap().into_run();
1169
1170        store
1171            .update_run_status(run.id, RunStatus::Running)
1172            .await
1173            .unwrap();
1174
1175        let filter = RunFilter {
1176            status: Some(RunStatus::Running),
1177            ..RunFilter::default()
1178        };
1179        let page = store.list_runs(filter, 1, 20).await.unwrap();
1180        assert_eq!(page.total, 1);
1181        assert_eq!(page.items[0].id, run.id);
1182    }
1183
1184    #[tokio::test]
1185    async fn list_runs_pagination() {
1186        let store = InMemoryStore::new();
1187        for i in 0..5 {
1188            store
1189                .create_run(new_run_req(&format!("wf-{i}")))
1190                .await
1191                .unwrap()
1192                .into_run();
1193        }
1194
1195        let page1 = store.list_runs(RunFilter::default(), 1, 2).await.unwrap();
1196        assert_eq!(page1.total, 5);
1197        assert_eq!(page1.items.len(), 2);
1198        assert_eq!(page1.page, 1);
1199        assert_eq!(page1.per_page, 2);
1200
1201        let page2 = store.list_runs(RunFilter::default(), 2, 2).await.unwrap();
1202        assert_eq!(page2.items.len(), 2);
1203
1204        let page3 = store.list_runs(RunFilter::default(), 3, 2).await.unwrap();
1205        assert_eq!(page3.items.len(), 1);
1206    }
1207
1208    // ---- worker lease ----
1209
1210    fn lease(worker_id: &str, ttl_secs: u64) -> Option<LeaseRequest> {
1211        Some(LeaseRequest {
1212            worker_id: worker_id.to_string(),
1213            ttl: Duration::from_secs(ttl_secs),
1214        })
1215    }
1216
1217    /// Pick a run holding a lease that is already in the past.
1218    ///
1219    /// The short sleep matters: `Utc::now()` has microsecond resolution, so a
1220    /// sub-microsecond TTL can still read as "not yet expired" in the same tick.
1221    async fn pick_with_expired_lease(store: &InMemoryStore, max_retries: u32) -> Run {
1222        let mut req = new_run_req("test");
1223        req.max_retries = max_retries;
1224        store.create_run(req).await.unwrap();
1225        let picked = expire_now(store).await;
1226        picked.expect("a pending run was just created")
1227    }
1228
1229    /// Pick the next pending run with a lease that is expired by the time this
1230    /// returns.
1231    async fn expire_now(store: &InMemoryStore) -> Option<Run> {
1232        let picked = store
1233            .pick_next_pending(Some(LeaseRequest {
1234                worker_id: "worker-1".to_string(),
1235                ttl: Duration::from_nanos(1),
1236            }))
1237            .await
1238            .unwrap();
1239        sleep(Duration::from_millis(2)).await;
1240        picked
1241    }
1242
1243    #[tokio::test]
1244    async fn pick_next_pending_attaches_lease() {
1245        let store = InMemoryStore::new();
1246        store.create_run(new_run_req("test")).await.unwrap();
1247
1248        let picked = store
1249            .pick_next_pending(lease("worker-1", 90))
1250            .await
1251            .unwrap()
1252            .unwrap();
1253
1254        assert_eq!(picked.worker_id.as_deref(), Some("worker-1"));
1255        let expires = picked.lease_expires_at.expect("lease set");
1256        assert!(expires > Utc::now());
1257        assert!(expires <= Utc::now() + TimeDelta::seconds(91));
1258    }
1259
1260    #[tokio::test]
1261    async fn pick_next_pending_without_lease_leaves_run_unowned() {
1262        let store = InMemoryStore::new();
1263        store.create_run(new_run_req("test")).await.unwrap();
1264
1265        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
1266
1267        assert!(picked.worker_id.is_none());
1268        assert!(picked.lease_expires_at.is_none());
1269    }
1270
1271    #[tokio::test]
1272    async fn renew_lease_extends_expiry_for_owner() {
1273        let store = InMemoryStore::new();
1274        store.create_run(new_run_req("test")).await.unwrap();
1275        let picked = store
1276            .pick_next_pending(lease("worker-1", 1))
1277            .await
1278            .unwrap()
1279            .unwrap();
1280
1281        let renewed = store
1282            .renew_lease(picked.id, lease("worker-1", 90).unwrap())
1283            .await
1284            .unwrap();
1285
1286        assert!(renewed > picked.lease_expires_at.unwrap());
1287        let after = store.get_run(picked.id).await.unwrap().unwrap();
1288        assert_eq!(after.lease_expires_at, Some(renewed));
1289    }
1290
1291    #[tokio::test]
1292    async fn renew_lease_rejects_other_worker() {
1293        let store = InMemoryStore::new();
1294        store.create_run(new_run_req("test")).await.unwrap();
1295        let picked = store
1296            .pick_next_pending(lease("worker-1", 90))
1297            .await
1298            .unwrap()
1299            .unwrap();
1300
1301        let err = store
1302            .renew_lease(picked.id, lease("worker-2", 90).unwrap())
1303            .await
1304            .unwrap_err();
1305
1306        assert!(matches!(
1307            err,
1308            StoreError::LeaseLost { held_by: Some(ref w), .. } if w == "worker-1"
1309        ));
1310    }
1311
1312    #[tokio::test]
1313    async fn renew_lease_rejects_run_that_left_running() {
1314        let store = InMemoryStore::new();
1315        store.create_run(new_run_req("test")).await.unwrap();
1316        let picked = store
1317            .pick_next_pending(lease("worker-1", 90))
1318            .await
1319            .unwrap()
1320            .unwrap();
1321        store
1322            .update_run_status(picked.id, RunStatus::Cancelled)
1323            .await
1324            .unwrap();
1325
1326        let err = store
1327            .renew_lease(picked.id, lease("worker-1", 90).unwrap())
1328            .await
1329            .unwrap_err();
1330
1331        assert!(matches!(err, StoreError::LeaseLost { .. }));
1332    }
1333
1334    #[tokio::test]
1335    async fn renew_lease_on_unknown_run_is_not_found() {
1336        let store = InMemoryStore::new();
1337
1338        let err = store
1339            .renew_lease(Uuid::now_v7(), lease("worker-1", 90).unwrap())
1340            .await
1341            .unwrap_err();
1342
1343        assert!(matches!(err, StoreError::RunNotFound(_)));
1344    }
1345
1346    #[tokio::test]
1347    async fn leaving_running_clears_the_lease() {
1348        for target in [
1349            RunStatus::Completed,
1350            RunStatus::Retrying,
1351            RunStatus::AwaitingApproval,
1352        ] {
1353            let store = InMemoryStore::new();
1354            store.create_run(new_run_req("test")).await.unwrap();
1355            let picked = store
1356                .pick_next_pending(lease("worker-1", 90))
1357                .await
1358                .unwrap()
1359                .unwrap();
1360
1361            store.update_run_status(picked.id, target).await.unwrap();
1362
1363            let after = store.get_run(picked.id).await.unwrap().unwrap();
1364            assert!(after.worker_id.is_none(), "worker_id kept for {target}");
1365            assert!(
1366                after.lease_expires_at.is_none(),
1367                "lease_expires_at kept for {target}"
1368            );
1369        }
1370    }
1371
1372    #[tokio::test]
1373    async fn update_run_to_terminal_clears_the_lease() {
1374        let store = InMemoryStore::new();
1375        store.create_run(new_run_req("test")).await.unwrap();
1376        let picked = store
1377            .pick_next_pending(lease("worker-1", 90))
1378            .await
1379            .unwrap()
1380            .unwrap();
1381
1382        store
1383            .update_run(
1384                picked.id,
1385                RunUpdate {
1386                    status: Some(RunStatus::Failed),
1387                    ..RunUpdate::default()
1388                },
1389            )
1390            .await
1391            .unwrap();
1392
1393        let after = store.get_run(picked.id).await.unwrap().unwrap();
1394        assert!(after.worker_id.is_none());
1395        assert!(after.lease_expires_at.is_none());
1396    }
1397
1398    // ---- reap_expired_leases ----
1399
1400    #[tokio::test]
1401    async fn reap_expired_leases_empty_store() {
1402        let store = InMemoryStore::new();
1403        assert!(store.reap_expired_leases(100).await.unwrap().is_empty());
1404    }
1405
1406    #[tokio::test]
1407    async fn reap_expired_leases_requeues_run() {
1408        let store = InMemoryStore::new();
1409        let picked = pick_with_expired_lease(&store, 3).await;
1410
1411        let reaped = store.reap_expired_leases(100).await.unwrap();
1412
1413        assert_eq!(reaped.len(), 1);
1414        assert_eq!(reaped[0].from, RunStatus::Running);
1415        assert_eq!(reaped[0].to, RunStatus::Pending);
1416
1417        let after = store.get_run(picked.id).await.unwrap().unwrap();
1418        assert_eq!(after.status.state, RunStatus::Pending);
1419        assert_eq!(after.retry_count, 0);
1420        assert_eq!(after.lease_recoveries, 1);
1421        assert!(after.error.is_none());
1422        assert!(after.worker_id.is_none());
1423        assert!(after.lease_expires_at.is_none());
1424    }
1425
1426    #[tokio::test]
1427    async fn reap_expired_leases_requeued_run_is_pickable_again() {
1428        let store = InMemoryStore::new();
1429        let picked = pick_with_expired_lease(&store, 3).await;
1430        store.reap_expired_leases(100).await.unwrap();
1431
1432        let repicked = store
1433            .pick_next_pending(lease("worker-2", 90))
1434            .await
1435            .unwrap()
1436            .unwrap();
1437
1438        assert_eq!(repicked.id, picked.id);
1439        assert_eq!(repicked.worker_id.as_deref(), Some("worker-2"));
1440    }
1441
1442    #[tokio::test]
1443    async fn reap_expired_leases_ignores_valid_lease() {
1444        let store = InMemoryStore::new();
1445        store.create_run(new_run_req("test")).await.unwrap();
1446        let picked = store
1447            .pick_next_pending(lease("worker-1", 90))
1448            .await
1449            .unwrap()
1450            .unwrap();
1451
1452        assert!(store.reap_expired_leases(100).await.unwrap().is_empty());
1453
1454        let after = store.get_run(picked.id).await.unwrap().unwrap();
1455        assert_eq!(after.status.state, RunStatus::Running);
1456        assert_eq!(after.retry_count, 0);
1457    }
1458
1459    #[tokio::test]
1460    async fn reap_expired_leases_ignores_run_without_lease() {
1461        let store = InMemoryStore::new();
1462        store.create_run(new_run_req("test")).await.unwrap();
1463        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
1464
1465        assert!(store.reap_expired_leases(100).await.unwrap().is_empty());
1466
1467        let after = store.get_run(picked.id).await.unwrap().unwrap();
1468        assert_eq!(after.status.state, RunStatus::Running);
1469    }
1470
1471    #[tokio::test]
1472    async fn reap_expired_leases_fails_run_when_retries_exhausted() {
1473        let store = InMemoryStore::new();
1474        let picked = pick_with_expired_lease(&store, 0).await;
1475
1476        let reaped = store.reap_expired_leases(100).await.unwrap();
1477
1478        assert_eq!(reaped[0].to, RunStatus::Failed);
1479        let after = store.get_run(picked.id).await.unwrap().unwrap();
1480        assert_eq!(after.status.state, RunStatus::Failed);
1481        assert_eq!(after.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
1482        assert!(after.completed_at.is_some());
1483    }
1484
1485    #[tokio::test]
1486    async fn reap_expired_leases_fails_after_max_retries_recoveries() {
1487        let store = InMemoryStore::new();
1488        let picked = pick_with_expired_lease(&store, 2).await;
1489
1490        // Recovery 1 and 2 requeue, recovery 3 gives up.
1491        for expected in [RunStatus::Pending, RunStatus::Pending, RunStatus::Failed] {
1492            let reaped = store.reap_expired_leases(100).await.unwrap();
1493            assert_eq!(reaped[0].to, expected);
1494            if expected == RunStatus::Pending {
1495                expire_now(&store).await;
1496            }
1497        }
1498
1499        let after = store.get_run(picked.id).await.unwrap().unwrap();
1500        assert_eq!(after.lease_recoveries, 3);
1501        assert_eq!(after.retry_count, 0);
1502        assert_eq!(after.error.as_deref(), Some(LEASE_EXPIRED_ERROR));
1503    }
1504
1505    #[tokio::test]
1506    async fn reap_expired_leases_keeps_the_attempt_number() {
1507        let store = InMemoryStore::new();
1508        let picked = pick_with_expired_lease(&store, 3).await;
1509        let before = store
1510            .create_step(new_step_req(picked.id, "build", 0))
1511            .await
1512            .unwrap();
1513
1514        store.reap_expired_leases(100).await.unwrap();
1515        let repicked = store
1516            .pick_next_pending(lease("worker-2", 90))
1517            .await
1518            .unwrap()
1519            .unwrap();
1520        let after = store
1521            .create_step(new_step_req(repicked.id, "build", 0))
1522            .await
1523            .unwrap();
1524
1525        assert_eq!(before.attempt, 1);
1526        assert_eq!(after.attempt, before.attempt);
1527        assert_eq!(repicked.retry_count, 0);
1528        assert_eq!(repicked.lease_recoveries, 1);
1529    }
1530
1531    #[tokio::test]
1532    async fn reap_expired_leases_counts_apart_from_handler_retries() {
1533        let store = InMemoryStore::new();
1534        let picked = pick_with_expired_lease(&store, 1).await;
1535        store
1536            .update_run(
1537                picked.id,
1538                RunUpdate {
1539                    increment_retry: true,
1540                    ..RunUpdate::default()
1541                },
1542            )
1543            .await
1544            .unwrap();
1545
1546        // One handler retry already consumed max_retries: the lease recovery
1547        // budget is separate, so the first reap still requeues.
1548        let reaped = store.reap_expired_leases(100).await.unwrap();
1549
1550        assert_eq!(reaped[0].to, RunStatus::Pending);
1551        assert_eq!(reaped[0].run.retry_count, 1);
1552        assert_eq!(reaped[0].run.lease_recoveries, 1);
1553    }
1554
1555    #[tokio::test]
1556    async fn reap_expired_leases_respects_limit() {
1557        let store = InMemoryStore::new();
1558        for _ in 0..3 {
1559            pick_with_expired_lease(&store, 3).await;
1560        }
1561
1562        let reaped = store.reap_expired_leases(2).await.unwrap();
1563        assert_eq!(reaped.len(), 2);
1564
1565        let rest = store.reap_expired_leases(100).await.unwrap();
1566        assert_eq!(rest.len(), 1);
1567    }
1568
1569    // ---- pick_next_pending ----
1570
1571    #[tokio::test]
1572    async fn pick_next_pending_empty_store() {
1573        let store = InMemoryStore::new();
1574        let result = store.pick_next_pending(None).await.unwrap();
1575        assert!(result.is_none());
1576    }
1577
1578    #[tokio::test]
1579    async fn pick_next_pending_returns_oldest_and_transitions_to_running() {
1580        let store = InMemoryStore::new();
1581        let r1 = store
1582            .create_run(new_run_req("first"))
1583            .await
1584            .unwrap()
1585            .into_run();
1586        let _r2 = store
1587            .create_run(new_run_req("second"))
1588            .await
1589            .unwrap()
1590            .into_run();
1591
1592        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
1593        assert_eq!(picked.id, r1.id);
1594        assert_eq!(picked.status.state, RunStatus::Running);
1595        assert!(picked.started_at.is_some());
1596
1597        // Verify it's Running in the store too.
1598        let fetched = store.get_run(r1.id).await.unwrap().unwrap();
1599        assert_eq!(fetched.status.state, RunStatus::Running);
1600    }
1601
1602    #[tokio::test]
1603    async fn pick_next_pending_skips_non_pending() {
1604        let store = InMemoryStore::new();
1605        let r1 = store.create_run(new_run_req("a")).await.unwrap().into_run();
1606        let r2 = store.create_run(new_run_req("b")).await.unwrap().into_run();
1607
1608        // Transition r1 to Running.
1609        store
1610            .update_run_status(r1.id, RunStatus::Running)
1611            .await
1612            .unwrap();
1613
1614        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
1615        assert_eq!(picked.id, r2.id);
1616    }
1617
1618    // ---- create_step ----
1619
1620    #[tokio::test]
1621    async fn create_step_returns_pending() {
1622        let store = InMemoryStore::new();
1623        let run = store
1624            .create_run(new_run_req("test"))
1625            .await
1626            .unwrap()
1627            .into_run();
1628
1629        let step = store
1630            .create_step(NewStep {
1631                run_id: run.id,
1632                trace_id: step_trace_id(run.id, "build", 0),
1633                name: "build".to_string(),
1634                kind: crate::entities::StepKind::Shell,
1635                position: 0,
1636                input: Some(json!({"command": "cargo build"})),
1637                is_error_handler: false,
1638            })
1639            .await
1640            .unwrap();
1641
1642        assert_eq!(step.status.state, StepStatus::Pending);
1643        assert_eq!(step.name, "build");
1644        assert_eq!(step.run_id, run.id);
1645        assert_eq!(step.position, 0);
1646    }
1647
1648    #[tokio::test]
1649    async fn create_step_for_missing_run_returns_error() {
1650        let store = InMemoryStore::new();
1651        let result = store
1652            .create_step(NewStep {
1653                run_id: Uuid::nil(),
1654                trace_id: step_trace_id(Uuid::nil(), "build", 0),
1655                name: "build".to_string(),
1656                kind: crate::entities::StepKind::Shell,
1657                position: 0,
1658                input: None,
1659                is_error_handler: false,
1660            })
1661            .await;
1662        assert!(matches!(result.unwrap_err(), StoreError::RunNotFound(_)));
1663    }
1664
1665    // ---- update_step ----
1666
1667    #[tokio::test]
1668    async fn update_step_applies_partial_update() {
1669        let store = InMemoryStore::new();
1670        let run = store
1671            .create_run(new_run_req("test"))
1672            .await
1673            .unwrap()
1674            .into_run();
1675
1676        let step = store
1677            .create_step(NewStep {
1678                run_id: run.id,
1679                trace_id: step_trace_id(run.id, "build", 0),
1680                name: "build".to_string(),
1681                kind: crate::entities::StepKind::Shell,
1682                position: 0,
1683                input: None,
1684                is_error_handler: false,
1685            })
1686            .await
1687            .unwrap();
1688
1689        // Transition Pending → Running first
1690        store
1691            .update_step(
1692                step.id,
1693                StepUpdate {
1694                    status: Some(StepStatus::Running),
1695                    ..StepUpdate::default()
1696                },
1697            )
1698            .await
1699            .unwrap();
1700
1701        // Then Running → Completed
1702        store
1703            .update_step(
1704                step.id,
1705                StepUpdate {
1706                    status: Some(StepStatus::Completed),
1707                    output: Some(json!({"stdout": "ok"})),
1708                    duration_ms: Some(150),
1709                    ..StepUpdate::default()
1710                },
1711            )
1712            .await
1713            .unwrap();
1714
1715        let steps = store.list_steps(run.id).await.unwrap();
1716        assert_eq!(steps.len(), 1);
1717        assert_eq!(steps[0].status.state, StepStatus::Completed);
1718        assert_eq!(steps[0].duration_ms, 150);
1719        assert!(steps[0].output.is_some());
1720    }
1721
1722    #[tokio::test]
1723    async fn update_step_not_found() {
1724        let store = InMemoryStore::new();
1725        let result = store.update_step(Uuid::nil(), StepUpdate::default()).await;
1726        assert!(matches!(result.unwrap_err(), StoreError::StepNotFound(_)));
1727    }
1728
1729    // ---- list_steps ----
1730
1731    #[tokio::test]
1732    async fn list_steps_ordered_by_position() {
1733        let store = InMemoryStore::new();
1734        let run = store
1735            .create_run(new_run_req("test"))
1736            .await
1737            .unwrap()
1738            .into_run();
1739
1740        // Insert out of order.
1741        store
1742            .create_step(NewStep {
1743                run_id: run.id,
1744                trace_id: step_trace_id(run.id, "deploy", 2),
1745                name: "deploy".to_string(),
1746                kind: crate::entities::StepKind::Shell,
1747                position: 2,
1748                input: None,
1749                is_error_handler: false,
1750            })
1751            .await
1752            .unwrap();
1753        store
1754            .create_step(NewStep {
1755                run_id: run.id,
1756                trace_id: step_trace_id(run.id, "build", 0),
1757                name: "build".to_string(),
1758                kind: crate::entities::StepKind::Shell,
1759                position: 0,
1760                input: None,
1761                is_error_handler: false,
1762            })
1763            .await
1764            .unwrap();
1765        store
1766            .create_step(NewStep {
1767                run_id: run.id,
1768                trace_id: step_trace_id(run.id, "test", 1),
1769                name: "test".to_string(),
1770                kind: crate::entities::StepKind::Shell,
1771                position: 1,
1772                input: None,
1773                is_error_handler: false,
1774            })
1775            .await
1776            .unwrap();
1777
1778        let steps = store.list_steps(run.id).await.unwrap();
1779        assert_eq!(steps.len(), 3);
1780        assert_eq!(steps[0].name, "build");
1781        assert_eq!(steps[1].name, "test");
1782        assert_eq!(steps[2].name, "deploy");
1783    }
1784
1785    #[tokio::test]
1786    async fn list_steps_empty_for_run_without_steps() {
1787        let store = InMemoryStore::new();
1788        let run = store
1789            .create_run(new_run_req("test"))
1790            .await
1791            .unwrap()
1792            .into_run();
1793        let steps = store.list_steps(run.id).await.unwrap();
1794        assert!(steps.is_empty());
1795    }
1796
1797    // ---- update_run ----
1798
1799    #[tokio::test]
1800    async fn update_run_applies_cost_and_duration() {
1801        let store = InMemoryStore::new();
1802        let run = store
1803            .create_run(new_run_req("test"))
1804            .await
1805            .unwrap()
1806            .into_run();
1807
1808        store
1809            .update_run(
1810                run.id,
1811                RunUpdate {
1812                    cost_usd: Some(Decimal::new(123, 2)),
1813                    duration_ms: Some(5000),
1814                    ..RunUpdate::default()
1815                },
1816            )
1817            .await
1818            .unwrap();
1819
1820        let fetched = store.get_run(run.id).await.unwrap().unwrap();
1821        assert_eq!(fetched.cost_usd, Decimal::new(123, 2));
1822        assert_eq!(fetched.duration_ms, 5000);
1823    }
1824
1825    #[tokio::test]
1826    async fn update_run_increment_retry() {
1827        let store = InMemoryStore::new();
1828        let run = store
1829            .create_run(new_run_req("test"))
1830            .await
1831            .unwrap()
1832            .into_run();
1833        assert_eq!(run.retry_count, 0);
1834
1835        store
1836            .update_run(
1837                run.id,
1838                RunUpdate {
1839                    increment_retry: true,
1840                    ..RunUpdate::default()
1841                },
1842            )
1843            .await
1844            .unwrap();
1845
1846        let fetched = store.get_run(run.id).await.unwrap().unwrap();
1847        assert_eq!(fetched.retry_count, 1);
1848    }
1849
1850    #[tokio::test]
1851    async fn update_run_not_found() {
1852        let store = InMemoryStore::new();
1853        let result = store.update_run(Uuid::nil(), RunUpdate::default()).await;
1854        assert!(matches!(result.unwrap_err(), StoreError::RunNotFound(_)));
1855    }
1856
1857    // ---- concurrent access ----
1858
1859    #[tokio::test]
1860    async fn concurrent_pick_next_pending_no_double_pick() {
1861        let store = InMemoryStore::new();
1862
1863        // Create 10 pending runs.
1864        for i in 0..10 {
1865            store
1866                .create_run(new_run_req(&format!("wf-{i}")))
1867                .await
1868                .unwrap()
1869                .into_run();
1870        }
1871
1872        // Concurrently pick from multiple tasks.
1873        let mut handles = Vec::new();
1874        for _ in 0..10 {
1875            let s = store.clone();
1876            handles.push(spawn(async move { s.pick_next_pending(None).await }));
1877        }
1878
1879        let mut picked_ids = Vec::new();
1880        for h in handles {
1881            if let Ok(Ok(Some(run))) = h.await {
1882                picked_ids.push(run.id);
1883            }
1884        }
1885
1886        // Each run should be picked at most once.
1887        let unique: std::collections::HashSet<_> = picked_ids.iter().collect();
1888        assert_eq!(unique.len(), picked_ids.len());
1889    }
1890
1891    // ---- get_stats ----
1892
1893    #[tokio::test]
1894    async fn get_stats_empty_store() {
1895        let store = InMemoryStore::new();
1896        let stats = store.get_stats(RunFilter::default()).await.unwrap();
1897        assert_eq!(stats.total_runs, 0);
1898        assert_eq!(stats.completed_runs, 0);
1899        assert_eq!(stats.failed_runs, 0);
1900        assert_eq!(stats.cancelled_runs, 0);
1901        assert_eq!(stats.active_runs, 0);
1902        assert_eq!(stats.total_cost_usd, Decimal::ZERO);
1903        assert_eq!(stats.total_duration_ms, 0);
1904    }
1905
1906    #[tokio::test]
1907    async fn get_stats_aggregates_counts_and_totals() {
1908        let store = InMemoryStore::new();
1909
1910        // Create runs in various states.
1911        let r1 = store
1912            .create_run(new_run_req("wf1"))
1913            .await
1914            .unwrap()
1915            .into_run();
1916        let r2 = store
1917            .create_run(new_run_req("wf2"))
1918            .await
1919            .unwrap()
1920            .into_run();
1921        let r3 = store
1922            .create_run(new_run_req("wf3"))
1923            .await
1924            .unwrap()
1925            .into_run();
1926        let _r4 = store
1927            .create_run(new_run_req("wf4"))
1928            .await
1929            .unwrap()
1930            .into_run();
1931
1932        // Transition r1 to Running, then Completed.
1933        store
1934            .update_run_status(r1.id, RunStatus::Running)
1935            .await
1936            .unwrap();
1937        store
1938            .update_run_status(r1.id, RunStatus::Completed)
1939            .await
1940            .unwrap();
1941
1942        // Transition r2 to Running, then Failed.
1943        store
1944            .update_run_status(r2.id, RunStatus::Running)
1945            .await
1946            .unwrap();
1947        store
1948            .update_run_status(r2.id, RunStatus::Failed)
1949            .await
1950            .unwrap();
1951
1952        // Transition r3 to Cancelled.
1953        store
1954            .update_run_status(r3.id, RunStatus::Cancelled)
1955            .await
1956            .unwrap();
1957
1958        // r4 remains Pending (active).
1959
1960        // Update cost and duration.
1961        store
1962            .update_run(
1963                r1.id,
1964                RunUpdate {
1965                    cost_usd: Some(Decimal::new(1000, 2)),
1966                    duration_ms: Some(1000),
1967                    ..RunUpdate::default()
1968                },
1969            )
1970            .await
1971            .unwrap();
1972
1973        store
1974            .update_run(
1975                r2.id,
1976                RunUpdate {
1977                    cost_usd: Some(Decimal::new(500, 2)),
1978                    duration_ms: Some(500),
1979                    ..RunUpdate::default()
1980                },
1981            )
1982            .await
1983            .unwrap();
1984
1985        let stats = store.get_stats(RunFilter::default()).await.unwrap();
1986        assert_eq!(stats.total_runs, 4);
1987        assert_eq!(stats.completed_runs, 1);
1988        assert_eq!(stats.failed_runs, 1);
1989        assert_eq!(stats.cancelled_runs, 1);
1990        assert_eq!(stats.active_runs, 1); // r4 is Pending
1991        assert_eq!(stats.total_cost_usd, Decimal::new(1500, 2));
1992        assert_eq!(stats.total_duration_ms, 1500);
1993    }
1994
1995    #[tokio::test]
1996    async fn update_run_status_running_to_retrying() {
1997        let store = InMemoryStore::new();
1998        let run = store
1999            .create_run(new_run_req("test"))
2000            .await
2001            .unwrap()
2002            .into_run();
2003
2004        store
2005            .update_run_status(run.id, RunStatus::Running)
2006            .await
2007            .unwrap();
2008
2009        store
2010            .update_run_status(run.id, RunStatus::Retrying)
2011            .await
2012            .unwrap();
2013
2014        let fetched = store.get_run(run.id).await.unwrap().unwrap();
2015        assert_eq!(fetched.status.state, RunStatus::Retrying);
2016        assert!(!fetched.status.state.is_terminal());
2017        assert!(fetched.completed_at.is_none()); // Not a terminal state
2018    }
2019
2020    #[tokio::test]
2021    async fn update_run_status_retrying_to_running_allowed() {
2022        let store = InMemoryStore::new();
2023        let run = store
2024            .create_run(new_run_req("test"))
2025            .await
2026            .unwrap()
2027            .into_run();
2028
2029        store
2030            .update_run_status(run.id, RunStatus::Running)
2031            .await
2032            .unwrap();
2033        store
2034            .update_run_status(run.id, RunStatus::Retrying)
2035            .await
2036            .unwrap();
2037
2038        // Retrying → Running should be allowed
2039        store
2040            .update_run_status(run.id, RunStatus::Running)
2041            .await
2042            .unwrap();
2043
2044        let fetched = store.get_run(run.id).await.unwrap().unwrap();
2045        assert_eq!(fetched.status.state, RunStatus::Running);
2046    }
2047
2048    #[tokio::test]
2049    async fn update_run_with_invalid_status_transition_errors() {
2050        let store = InMemoryStore::new();
2051        let run = store
2052            .create_run(new_run_req("test"))
2053            .await
2054            .unwrap()
2055            .into_run();
2056
2057        // Try to apply invalid status transition via update_run
2058        let result = store
2059            .update_run(
2060                run.id,
2061                RunUpdate {
2062                    status: Some(RunStatus::Completed), // invalid from Pending
2063                    ..RunUpdate::default()
2064                },
2065            )
2066            .await;
2067
2068        assert!(result.is_err());
2069    }
2070
2071    #[tokio::test]
2072    async fn create_step_with_complex_input() {
2073        let store = InMemoryStore::new();
2074        let run = store
2075            .create_run(new_run_req("test"))
2076            .await
2077            .unwrap()
2078            .into_run();
2079
2080        let complex_input = json!({
2081            "command": "cargo build",
2082            "env": {
2083                "RUST_LOG": "debug",
2084                "CUSTOM": "value"
2085            },
2086            "timeout": 60,
2087            "retry_policy": {
2088                "max_attempts": 3,
2089                "backoff": "exponential"
2090            }
2091        });
2092
2093        let step = store
2094            .create_step(NewStep {
2095                run_id: run.id,
2096                trace_id: step_trace_id(run.id, "build", 0),
2097                name: "build".to_string(),
2098                kind: crate::entities::StepKind::Agent,
2099                position: 0,
2100                input: Some(complex_input.clone()),
2101                is_error_handler: false,
2102            })
2103            .await
2104            .unwrap();
2105
2106        assert_eq!(step.input, Some(complex_input));
2107    }
2108
2109    #[tokio::test]
2110    async fn update_step_with_error_message() {
2111        let store = InMemoryStore::new();
2112        let run = store
2113            .create_run(new_run_req("test"))
2114            .await
2115            .unwrap()
2116            .into_run();
2117
2118        let step = store
2119            .create_step(NewStep {
2120                run_id: run.id,
2121                trace_id: step_trace_id(run.id, "build", 0),
2122                name: "build".to_string(),
2123                kind: crate::entities::StepKind::Shell,
2124                position: 0,
2125                input: None,
2126                is_error_handler: false,
2127            })
2128            .await
2129            .unwrap();
2130
2131        store
2132            .update_step(
2133                step.id,
2134                StepUpdate {
2135                    status: Some(StepStatus::Running),
2136                    ..StepUpdate::default()
2137                },
2138            )
2139            .await
2140            .unwrap();
2141
2142        store
2143            .update_step(
2144                step.id,
2145                StepUpdate {
2146                    status: Some(StepStatus::Failed),
2147                    error: Some("Connection timeout after 30s".to_string()),
2148                    duration_ms: Some(30000),
2149                    ..StepUpdate::default()
2150                },
2151            )
2152            .await
2153            .unwrap();
2154
2155        let steps = store.list_steps(run.id).await.unwrap();
2156        assert_eq!(steps[0].status.state, StepStatus::Failed);
2157        assert_eq!(
2158            steps[0].error,
2159            Some("Connection timeout after 30s".to_string())
2160        );
2161        assert_eq!(steps[0].duration_ms, 30000);
2162    }
2163
2164    #[tokio::test]
2165    async fn list_steps_for_nonexistent_run_returns_empty() {
2166        let store = InMemoryStore::new();
2167        let steps = store.list_steps(Uuid::nil()).await.unwrap();
2168        assert!(steps.is_empty());
2169    }
2170
2171    #[tokio::test]
2172    async fn update_step_pending_to_skipped() {
2173        let store = InMemoryStore::new();
2174        let run = store
2175            .create_run(new_run_req("test"))
2176            .await
2177            .unwrap()
2178            .into_run();
2179
2180        let step = store
2181            .create_step(NewStep {
2182                run_id: run.id,
2183                trace_id: step_trace_id(run.id, "build", 0),
2184                name: "build".to_string(),
2185                kind: crate::entities::StepKind::Shell,
2186                position: 0,
2187                input: None,
2188                is_error_handler: false,
2189            })
2190            .await
2191            .unwrap();
2192
2193        // Pending → Skipped is allowed (when prior step failed)
2194        store
2195            .update_step(
2196                step.id,
2197                StepUpdate {
2198                    status: Some(StepStatus::Skipped),
2199                    ..StepUpdate::default()
2200                },
2201            )
2202            .await
2203            .unwrap();
2204
2205        let steps = store.list_steps(run.id).await.unwrap();
2206        assert_eq!(steps[0].status.state, StepStatus::Skipped);
2207    }
2208
2209    #[tokio::test]
2210    async fn list_runs_with_combined_filters() {
2211        let store = InMemoryStore::new();
2212
2213        let r1 = store
2214            .create_run(new_run_req("deploy"))
2215            .await
2216            .unwrap()
2217            .into_run();
2218        let r2 = store
2219            .create_run(new_run_req("deploy"))
2220            .await
2221            .unwrap()
2222            .into_run();
2223        let _r3 = store
2224            .create_run(new_run_req("test"))
2225            .await
2226            .unwrap()
2227            .into_run();
2228
2229        // r1: Pending → Running → Completed
2230        store
2231            .update_run_status(r1.id, RunStatus::Running)
2232            .await
2233            .unwrap();
2234        store
2235            .update_run_status(r1.id, RunStatus::Completed)
2236            .await
2237            .unwrap();
2238
2239        // r2: Pending → Running
2240        store
2241            .update_run_status(r2.id, RunStatus::Running)
2242            .await
2243            .unwrap();
2244
2245        // Filter by workflow AND status
2246        let filter = RunFilter {
2247            workflow_name: Some("deploy".to_string()),
2248            status: Some(RunStatus::Running),
2249            ..RunFilter::default()
2250        };
2251
2252        let page = store.list_runs(filter, 1, 100).await.unwrap();
2253        assert_eq!(page.total, 1);
2254        assert_eq!(page.items[0].id, r2.id);
2255    }
2256
2257    #[tokio::test]
2258    async fn list_runs_workflow_filter_is_case_insensitive_partial_match() {
2259        let store = InMemoryStore::new();
2260        store
2261            .create_run(new_run_req("weather-report"))
2262            .await
2263            .unwrap()
2264            .into_run();
2265        store
2266            .create_run(new_run_req("deploy-prod"))
2267            .await
2268            .unwrap()
2269            .into_run();
2270
2271        // Partial match
2272        let filter = RunFilter {
2273            workflow_name: Some("weather".to_string()),
2274            ..RunFilter::default()
2275        };
2276        let page = store.list_runs(filter, 1, 100).await.unwrap();
2277        assert_eq!(page.total, 1);
2278        assert_eq!(page.items[0].workflow_name, "weather-report");
2279
2280        // Case-insensitive
2281        let filter = RunFilter {
2282            workflow_name: Some("Weather-REPORT".to_string()),
2283            ..RunFilter::default()
2284        };
2285        let page = store.list_runs(filter, 1, 100).await.unwrap();
2286        assert_eq!(page.total, 1);
2287        assert_eq!(page.items[0].workflow_name, "weather-report");
2288
2289        // Suffix match
2290        let filter = RunFilter {
2291            workflow_name: Some("report".to_string()),
2292            ..RunFilter::default()
2293        };
2294        let page = store.list_runs(filter, 1, 100).await.unwrap();
2295        assert_eq!(page.total, 1);
2296        assert_eq!(page.items[0].workflow_name, "weather-report");
2297
2298        // No match
2299        let filter = RunFilter {
2300            workflow_name: Some("build".to_string()),
2301            ..RunFilter::default()
2302        };
2303        let page = store.list_runs(filter, 1, 100).await.unwrap();
2304        assert_eq!(page.total, 0);
2305    }
2306
2307    #[tokio::test]
2308    async fn list_runs_has_steps_true_only_filters_completed_and_cancelled() {
2309        let store = InMemoryStore::new();
2310        let run_with = create_terminal_run(&store, "with-steps", RunStatus::Completed).await;
2311        let _run_without = create_terminal_run(&store, "without-steps", RunStatus::Completed).await;
2312
2313        store
2314            .create_step(NewStep {
2315                run_id: run_with.id,
2316                trace_id: step_trace_id(run_with.id, "build", 0),
2317                name: "build".to_string(),
2318                kind: crate::entities::StepKind::Shell,
2319                position: 0,
2320                input: None,
2321                is_error_handler: false,
2322            })
2323            .await
2324            .unwrap();
2325
2326        let filter = RunFilter {
2327            has_steps: Some(true),
2328            ..RunFilter::default()
2329        };
2330        let page = store.list_runs(filter, 1, 100).await.unwrap();
2331        assert_eq!(page.total, 1);
2332        assert_eq!(page.items[0].id, run_with.id);
2333    }
2334
2335    #[tokio::test]
2336    async fn list_runs_has_steps_false_only_filters_completed_and_cancelled() {
2337        let store = InMemoryStore::new();
2338        let run_with = create_terminal_run(&store, "with-steps", RunStatus::Cancelled).await;
2339        let run_without = create_terminal_run(&store, "without-steps", RunStatus::Cancelled).await;
2340
2341        store
2342            .create_step(NewStep {
2343                run_id: run_with.id,
2344                trace_id: step_trace_id(run_with.id, "build", 0),
2345                name: "build".to_string(),
2346                kind: crate::entities::StepKind::Shell,
2347                position: 0,
2348                input: None,
2349                is_error_handler: false,
2350            })
2351            .await
2352            .unwrap();
2353
2354        let filter = RunFilter {
2355            has_steps: Some(false),
2356            ..RunFilter::default()
2357        };
2358        let page = store.list_runs(filter, 1, 100).await.unwrap();
2359        assert_eq!(page.total, 1);
2360        assert_eq!(page.items[0].id, run_without.id);
2361    }
2362
2363    #[tokio::test]
2364    async fn list_runs_has_steps_none_returns_all() {
2365        let store = InMemoryStore::new();
2366        let run_with = store
2367            .create_run(new_run_req("with-steps"))
2368            .await
2369            .unwrap()
2370            .into_run();
2371        let _run_without = store
2372            .create_run(new_run_req("without-steps"))
2373            .await
2374            .unwrap()
2375            .into_run();
2376
2377        store
2378            .create_step(NewStep {
2379                run_id: run_with.id,
2380                trace_id: step_trace_id(run_with.id, "build", 0),
2381                name: "build".to_string(),
2382                kind: crate::entities::StepKind::Shell,
2383                position: 0,
2384                input: None,
2385                is_error_handler: false,
2386            })
2387            .await
2388            .unwrap();
2389
2390        let filter = RunFilter {
2391            has_steps: None,
2392            ..RunFilter::default()
2393        };
2394        let page = store.list_runs(filter, 1, 100).await.unwrap();
2395        assert_eq!(page.total, 2);
2396    }
2397
2398    #[tokio::test]
2399    async fn list_runs_has_steps_true_does_not_filter_non_terminal_runs() {
2400        let store = InMemoryStore::new();
2401        let pending_run = store
2402            .create_run(new_run_req("pending-empty"))
2403            .await
2404            .unwrap()
2405            .into_run();
2406        let running_run = store
2407            .create_run(new_run_req("running-empty"))
2408            .await
2409            .unwrap()
2410            .into_run();
2411        store
2412            .update_run_status(running_run.id, RunStatus::Running)
2413            .await
2414            .unwrap();
2415
2416        let filter = RunFilter {
2417            has_steps: Some(true),
2418            ..RunFilter::default()
2419        };
2420        let page = store.list_runs(filter, 1, 100).await.unwrap();
2421        assert_eq!(page.total, 2);
2422        let ids: Vec<_> = page.items.iter().map(|r| r.id).collect();
2423        assert!(ids.contains(&pending_run.id));
2424        assert!(ids.contains(&running_run.id));
2425    }
2426
2427    #[tokio::test]
2428    async fn get_stats_with_mixed_active_statuses() {
2429        let store = InMemoryStore::new();
2430
2431        let _r1 = store
2432            .create_run(new_run_req("wf"))
2433            .await
2434            .unwrap()
2435            .into_run(); // Pending
2436        let r2 = store
2437            .create_run(new_run_req("wf"))
2438            .await
2439            .unwrap()
2440            .into_run();
2441        let r3 = store
2442            .create_run(new_run_req("wf"))
2443            .await
2444            .unwrap()
2445            .into_run();
2446
2447        store
2448            .update_run_status(r2.id, RunStatus::Running)
2449            .await
2450            .unwrap();
2451        store
2452            .update_run_status(r3.id, RunStatus::Running)
2453            .await
2454            .unwrap();
2455        store
2456            .update_run_status(r3.id, RunStatus::Retrying)
2457            .await
2458            .unwrap();
2459
2460        let r4 = store
2461            .create_run(new_run_req("wf"))
2462            .await
2463            .unwrap()
2464            .into_run();
2465        store
2466            .update_run_status(r4.id, RunStatus::Running)
2467            .await
2468            .unwrap();
2469        store
2470            .update_run_status(r4.id, RunStatus::AwaitingApproval)
2471            .await
2472            .unwrap();
2473
2474        let r5 = store
2475            .create_run(new_run_req("wf"))
2476            .await
2477            .unwrap()
2478            .into_run();
2479        store
2480            .update_run_status(r5.id, RunStatus::Running)
2481            .await
2482            .unwrap();
2483        store
2484            .update_run_status(r5.id, RunStatus::Sleeping)
2485            .await
2486            .unwrap();
2487
2488        let stats = store.get_stats(RunFilter::default()).await.unwrap();
2489        // _r1 (Pending), r2 (Running), r3 (Retrying), r4 (AwaitingApproval), r5 (Sleeping)
2490        assert_eq!(stats.active_runs, 5);
2491        assert_eq!(stats.awaiting_approval_runs, 1);
2492    }
2493
2494    #[tokio::test]
2495    async fn run_with_different_trigger_kinds() {
2496        let store = InMemoryStore::new();
2497
2498        let r1 = store
2499            .create_run(NewRun {
2500                created_by: None,
2501                workflow_name: "test".to_string(),
2502                trigger: TriggerKind::Manual,
2503                payload: json!({}),
2504                max_retries: 1,
2505                handler_version: None,
2506                labels: HashMap::new(),
2507                scheduled_at: None,
2508                idempotency_key: None,
2509                concurrency_key: None,
2510                max_cost_usd: None,
2511            })
2512            .await
2513            .unwrap()
2514            .into_run();
2515
2516        let r2 = store
2517            .create_run(NewRun {
2518                created_by: None,
2519                workflow_name: "test".to_string(),
2520                trigger: TriggerKind::Webhook {
2521                    path: "/hooks/github".to_string(),
2522                },
2523                payload: json!({}),
2524                max_retries: 1,
2525                handler_version: None,
2526                labels: HashMap::new(),
2527                scheduled_at: None,
2528                idempotency_key: None,
2529                concurrency_key: None,
2530                max_cost_usd: None,
2531            })
2532            .await
2533            .unwrap()
2534            .into_run();
2535
2536        let r3 = store
2537            .create_run(NewRun {
2538                created_by: None,
2539                workflow_name: "test".to_string(),
2540                trigger: TriggerKind::Cron {
2541                    schedule: "0 0 * * *".to_string(),
2542                },
2543                payload: json!({}),
2544                max_retries: 1,
2545                handler_version: None,
2546                labels: HashMap::new(),
2547                scheduled_at: None,
2548                idempotency_key: None,
2549                concurrency_key: None,
2550                max_cost_usd: None,
2551            })
2552            .await
2553            .unwrap()
2554            .into_run();
2555
2556        let r4 = store
2557            .create_run(NewRun {
2558                created_by: None,
2559                workflow_name: "test".to_string(),
2560                trigger: TriggerKind::Api,
2561                payload: json!({}),
2562                max_retries: 1,
2563                handler_version: None,
2564                labels: HashMap::new(),
2565                scheduled_at: None,
2566                idempotency_key: None,
2567                concurrency_key: None,
2568                max_cost_usd: None,
2569            })
2570            .await
2571            .unwrap()
2572            .into_run();
2573
2574        let r5 = store
2575            .create_run(NewRun {
2576                created_by: None,
2577                workflow_name: "test".to_string(),
2578                trigger: TriggerKind::Retry {
2579                    parent_run_id: Uuid::nil(),
2580                },
2581                payload: json!({}),
2582                max_retries: 1,
2583                handler_version: None,
2584                labels: HashMap::new(),
2585                scheduled_at: None,
2586                idempotency_key: None,
2587                concurrency_key: None,
2588                max_cost_usd: None,
2589            })
2590            .await
2591            .unwrap()
2592            .into_run();
2593
2594        assert_eq!(r1.trigger, TriggerKind::Manual);
2595        assert!(matches!(r2.trigger, TriggerKind::Webhook { .. }));
2596        assert!(matches!(r3.trigger, TriggerKind::Cron { .. }));
2597        assert_eq!(r4.trigger, TriggerKind::Api);
2598        assert!(matches!(r5.trigger, TriggerKind::Retry { .. }));
2599    }
2600
2601    // ---- create_step_dependencies ----
2602
2603    #[tokio::test]
2604    async fn create_step_dependencies_stores_dependencies() {
2605        let store = InMemoryStore::new();
2606        let run = store
2607            .create_run(new_run_req("test"))
2608            .await
2609            .unwrap()
2610            .into_run();
2611
2612        let step1 = store
2613            .create_step(NewStep {
2614                run_id: run.id,
2615                trace_id: step_trace_id(run.id, "step1", 0),
2616                name: "step1".to_string(),
2617                kind: crate::entities::StepKind::Shell,
2618                position: 0,
2619                input: None,
2620                is_error_handler: false,
2621            })
2622            .await
2623            .unwrap();
2624
2625        let step2 = store
2626            .create_step(NewStep {
2627                run_id: run.id,
2628                trace_id: step_trace_id(run.id, "step2", 1),
2629                name: "step2".to_string(),
2630                kind: crate::entities::StepKind::Shell,
2631                position: 1,
2632                input: None,
2633                is_error_handler: false,
2634            })
2635            .await
2636            .unwrap();
2637
2638        let result = store
2639            .create_step_dependencies(vec![NewStepDependency {
2640                step_id: step2.id,
2641                depends_on: step1.id,
2642            }])
2643            .await;
2644
2645        assert!(result.is_ok());
2646
2647        let deps = store.list_step_dependencies(run.id).await.unwrap();
2648        assert_eq!(deps.len(), 1);
2649        assert_eq!(deps[0].step_id, step2.id);
2650        assert_eq!(deps[0].depends_on, step1.id);
2651    }
2652
2653    #[tokio::test]
2654    async fn create_step_dependencies_duplicate_dependencies_are_idempotent() {
2655        let store = InMemoryStore::new();
2656        let run = store
2657            .create_run(new_run_req("test"))
2658            .await
2659            .unwrap()
2660            .into_run();
2661
2662        let step1 = store
2663            .create_step(NewStep {
2664                run_id: run.id,
2665                trace_id: step_trace_id(run.id, "step1", 0),
2666                name: "step1".to_string(),
2667                kind: crate::entities::StepKind::Shell,
2668                position: 0,
2669                input: None,
2670                is_error_handler: false,
2671            })
2672            .await
2673            .unwrap();
2674
2675        let step2 = store
2676            .create_step(NewStep {
2677                run_id: run.id,
2678                trace_id: step_trace_id(run.id, "step2", 1),
2679                name: "step2".to_string(),
2680                kind: crate::entities::StepKind::Shell,
2681                position: 1,
2682                input: None,
2683                is_error_handler: false,
2684            })
2685            .await
2686            .unwrap();
2687
2688        let dep = NewStepDependency {
2689            step_id: step2.id,
2690            depends_on: step1.id,
2691        };
2692
2693        store
2694            .create_step_dependencies(vec![dep.clone()])
2695            .await
2696            .unwrap();
2697        store.create_step_dependencies(vec![dep]).await.unwrap();
2698
2699        let deps = store.list_step_dependencies(run.id).await.unwrap();
2700        assert_eq!(deps.len(), 1);
2701    }
2702
2703    #[tokio::test]
2704    async fn create_step_dependencies_missing_step_id_returns_error() {
2705        let store = InMemoryStore::new();
2706        let run = store
2707            .create_run(new_run_req("test"))
2708            .await
2709            .unwrap()
2710            .into_run();
2711
2712        let step1 = store
2713            .create_step(NewStep {
2714                run_id: run.id,
2715                trace_id: step_trace_id(run.id, "step1", 0),
2716                name: "step1".to_string(),
2717                kind: crate::entities::StepKind::Shell,
2718                position: 0,
2719                input: None,
2720                is_error_handler: false,
2721            })
2722            .await
2723            .unwrap();
2724
2725        let result = store
2726            .create_step_dependencies(vec![NewStepDependency {
2727                step_id: Uuid::nil(),
2728                depends_on: step1.id,
2729            }])
2730            .await;
2731
2732        assert!(matches!(result.unwrap_err(), StoreError::StepNotFound(_)));
2733    }
2734
2735    #[tokio::test]
2736    async fn create_step_dependencies_missing_depends_on_returns_error() {
2737        let store = InMemoryStore::new();
2738        let run = store
2739            .create_run(new_run_req("test"))
2740            .await
2741            .unwrap()
2742            .into_run();
2743
2744        let step1 = store
2745            .create_step(NewStep {
2746                run_id: run.id,
2747                trace_id: step_trace_id(run.id, "step1", 0),
2748                name: "step1".to_string(),
2749                kind: crate::entities::StepKind::Shell,
2750                position: 0,
2751                input: None,
2752                is_error_handler: false,
2753            })
2754            .await
2755            .unwrap();
2756
2757        let result = store
2758            .create_step_dependencies(vec![NewStepDependency {
2759                step_id: step1.id,
2760                depends_on: Uuid::nil(),
2761            }])
2762            .await;
2763
2764        assert!(matches!(result.unwrap_err(), StoreError::StepNotFound(_)));
2765    }
2766
2767    #[tokio::test]
2768    async fn create_step_dependencies_multiple_dependencies() {
2769        let store = InMemoryStore::new();
2770        let run = store
2771            .create_run(new_run_req("test"))
2772            .await
2773            .unwrap()
2774            .into_run();
2775
2776        let step1 = store
2777            .create_step(NewStep {
2778                run_id: run.id,
2779                trace_id: step_trace_id(run.id, "step1", 0),
2780                name: "step1".to_string(),
2781                kind: crate::entities::StepKind::Shell,
2782                position: 0,
2783                input: None,
2784                is_error_handler: false,
2785            })
2786            .await
2787            .unwrap();
2788
2789        let step2 = store
2790            .create_step(NewStep {
2791                run_id: run.id,
2792                trace_id: step_trace_id(run.id, "step2", 1),
2793                name: "step2".to_string(),
2794                kind: crate::entities::StepKind::Shell,
2795                position: 1,
2796                input: None,
2797                is_error_handler: false,
2798            })
2799            .await
2800            .unwrap();
2801
2802        let step3 = store
2803            .create_step(NewStep {
2804                run_id: run.id,
2805                trace_id: step_trace_id(run.id, "step3", 2),
2806                name: "step3".to_string(),
2807                kind: crate::entities::StepKind::Shell,
2808                position: 2,
2809                input: None,
2810                is_error_handler: false,
2811            })
2812            .await
2813            .unwrap();
2814
2815        let result = store
2816            .create_step_dependencies(vec![
2817                NewStepDependency {
2818                    step_id: step2.id,
2819                    depends_on: step1.id,
2820                },
2821                NewStepDependency {
2822                    step_id: step3.id,
2823                    depends_on: step2.id,
2824                },
2825            ])
2826            .await;
2827
2828        assert!(result.is_ok());
2829
2830        let deps = store.list_step_dependencies(run.id).await.unwrap();
2831        assert_eq!(deps.len(), 2);
2832    }
2833
2834    // ---- list_step_dependencies ----
2835
2836    #[tokio::test]
2837    async fn list_step_dependencies_empty_for_run_with_no_dependencies() {
2838        let store = InMemoryStore::new();
2839        let run = store
2840            .create_run(new_run_req("test"))
2841            .await
2842            .unwrap()
2843            .into_run();
2844
2845        store
2846            .create_step(NewStep {
2847                run_id: run.id,
2848                trace_id: step_trace_id(run.id, "step1", 0),
2849                name: "step1".to_string(),
2850                kind: crate::entities::StepKind::Shell,
2851                position: 0,
2852                input: None,
2853                is_error_handler: false,
2854            })
2855            .await
2856            .unwrap();
2857
2858        let deps = store.list_step_dependencies(run.id).await.unwrap();
2859        assert!(deps.is_empty());
2860    }
2861
2862    #[tokio::test]
2863    async fn list_step_dependencies_returns_only_deps_for_given_run() {
2864        let store = InMemoryStore::new();
2865        let run1 = store
2866            .create_run(new_run_req("test1"))
2867            .await
2868            .unwrap()
2869            .into_run();
2870        let run2 = store
2871            .create_run(new_run_req("test2"))
2872            .await
2873            .unwrap()
2874            .into_run();
2875
2876        let step1_run1 = store
2877            .create_step(NewStep {
2878                run_id: run1.id,
2879                trace_id: step_trace_id(run1.id, "step1", 0),
2880                name: "step1".to_string(),
2881                kind: crate::entities::StepKind::Shell,
2882                position: 0,
2883                input: None,
2884                is_error_handler: false,
2885            })
2886            .await
2887            .unwrap();
2888
2889        let step2_run1 = store
2890            .create_step(NewStep {
2891                run_id: run1.id,
2892                trace_id: step_trace_id(run1.id, "step2", 1),
2893                name: "step2".to_string(),
2894                kind: crate::entities::StepKind::Shell,
2895                position: 1,
2896                input: None,
2897                is_error_handler: false,
2898            })
2899            .await
2900            .unwrap();
2901
2902        let step1_run2 = store
2903            .create_step(NewStep {
2904                run_id: run2.id,
2905                trace_id: step_trace_id(run2.id, "step1", 0),
2906                name: "step1".to_string(),
2907                kind: crate::entities::StepKind::Shell,
2908                position: 0,
2909                input: None,
2910                is_error_handler: false,
2911            })
2912            .await
2913            .unwrap();
2914
2915        let step2_run2 = store
2916            .create_step(NewStep {
2917                run_id: run2.id,
2918                trace_id: step_trace_id(run2.id, "step2", 1),
2919                name: "step2".to_string(),
2920                kind: crate::entities::StepKind::Shell,
2921                position: 1,
2922                input: None,
2923                is_error_handler: false,
2924            })
2925            .await
2926            .unwrap();
2927
2928        store
2929            .create_step_dependencies(vec![
2930                NewStepDependency {
2931                    step_id: step2_run1.id,
2932                    depends_on: step1_run1.id,
2933                },
2934                NewStepDependency {
2935                    step_id: step2_run2.id,
2936                    depends_on: step1_run2.id,
2937                },
2938            ])
2939            .await
2940            .unwrap();
2941
2942        let deps_run1 = store.list_step_dependencies(run1.id).await.unwrap();
2943        let deps_run2 = store.list_step_dependencies(run2.id).await.unwrap();
2944
2945        assert_eq!(deps_run1.len(), 1);
2946        assert_eq!(deps_run1[0].step_id, step2_run1.id);
2947        assert_eq!(deps_run1[0].depends_on, step1_run1.id);
2948
2949        assert_eq!(deps_run2.len(), 1);
2950        assert_eq!(deps_run2[0].step_id, step2_run2.id);
2951        assert_eq!(deps_run2[0].depends_on, step1_run2.id);
2952    }
2953
2954    #[tokio::test]
2955    async fn list_step_dependencies_returns_empty_for_nonexistent_run() {
2956        let store = InMemoryStore::new();
2957        let deps = store.list_step_dependencies(Uuid::nil()).await.unwrap();
2958        assert!(deps.is_empty());
2959    }
2960
2961    #[tokio::test]
2962    async fn list_step_dependencies_sorted_by_created_at() {
2963        let store = InMemoryStore::new();
2964        let run = store
2965            .create_run(new_run_req("test"))
2966            .await
2967            .unwrap()
2968            .into_run();
2969
2970        let step1 = store
2971            .create_step(NewStep {
2972                run_id: run.id,
2973                trace_id: step_trace_id(run.id, "step1", 0),
2974                name: "step1".to_string(),
2975                kind: crate::entities::StepKind::Shell,
2976                position: 0,
2977                input: None,
2978                is_error_handler: false,
2979            })
2980            .await
2981            .unwrap();
2982
2983        let step2 = store
2984            .create_step(NewStep {
2985                run_id: run.id,
2986                trace_id: step_trace_id(run.id, "step2", 1),
2987                name: "step2".to_string(),
2988                kind: crate::entities::StepKind::Shell,
2989                position: 1,
2990                input: None,
2991                is_error_handler: false,
2992            })
2993            .await
2994            .unwrap();
2995
2996        let step3 = store
2997            .create_step(NewStep {
2998                run_id: run.id,
2999                trace_id: step_trace_id(run.id, "step3", 2),
3000                name: "step3".to_string(),
3001                kind: crate::entities::StepKind::Shell,
3002                position: 2,
3003                input: None,
3004                is_error_handler: false,
3005            })
3006            .await
3007            .unwrap();
3008
3009        store
3010            .create_step_dependencies(vec![NewStepDependency {
3011                step_id: step2.id,
3012                depends_on: step1.id,
3013            }])
3014            .await
3015            .unwrap();
3016
3017        store
3018            .create_step_dependencies(vec![NewStepDependency {
3019                step_id: step3.id,
3020                depends_on: step1.id,
3021            }])
3022            .await
3023            .unwrap();
3024
3025        let deps = store.list_step_dependencies(run.id).await.unwrap();
3026        assert_eq!(deps.len(), 2);
3027        assert!(deps[0].created_at <= deps[1].created_at);
3028    }
3029
3030    // ---- update_run_returning ----
3031
3032    #[tokio::test]
3033    async fn update_run_returning_applies_and_returns() {
3034        let store = InMemoryStore::new();
3035        let run = store
3036            .create_run(new_run_req("test"))
3037            .await
3038            .unwrap()
3039            .into_run();
3040
3041        // Transition Pending -> Running first
3042        store
3043            .update_run_status(run.id, RunStatus::Running)
3044            .await
3045            .unwrap();
3046
3047        let updated = store
3048            .update_run_returning(
3049                run.id,
3050                RunUpdate {
3051                    status: Some(RunStatus::Completed),
3052                    cost_usd: Some(Decimal::new(4200, 2)),
3053                    duration_ms: Some(1500),
3054                    ..RunUpdate::default()
3055                },
3056            )
3057            .await
3058            .unwrap();
3059
3060        assert_eq!(updated.id, run.id);
3061        assert_eq!(updated.status.state, RunStatus::Completed);
3062        assert_eq!(updated.cost_usd, Decimal::new(4200, 2));
3063        assert_eq!(updated.duration_ms, 1500);
3064        assert!(updated.completed_at.is_some());
3065    }
3066
3067    #[tokio::test]
3068    async fn update_run_returning_not_found() {
3069        let store = InMemoryStore::new();
3070        let result = store
3071            .update_run_returning(
3072                Uuid::nil(),
3073                RunUpdate {
3074                    status: Some(RunStatus::Running),
3075                    ..RunUpdate::default()
3076                },
3077            )
3078            .await;
3079
3080        assert!(matches!(result, Err(StoreError::RunNotFound(_))));
3081    }
3082
3083    #[tokio::test]
3084    async fn update_run_returning_invalid_transition() {
3085        let store = InMemoryStore::new();
3086        let run = store
3087            .create_run(new_run_req("test"))
3088            .await
3089            .unwrap()
3090            .into_run();
3091
3092        let result = store
3093            .update_run_returning(
3094                run.id,
3095                RunUpdate {
3096                    status: Some(RunStatus::Completed),
3097                    ..RunUpdate::default()
3098                },
3099            )
3100            .await;
3101
3102        assert!(matches!(result, Err(StoreError::InvalidTransition { .. })));
3103    }
3104
3105    // ---- retry scheduling ----
3106
3107    #[tokio::test]
3108    async fn create_step_stamps_the_current_attempt() {
3109        let store = InMemoryStore::new();
3110        let run = store
3111            .create_run(new_run_req("retry-wf"))
3112            .await
3113            .unwrap()
3114            .into_run();
3115
3116        let first = store
3117            .create_step(new_step_req(run.id, "build", 0))
3118            .await
3119            .unwrap();
3120        assert_eq!(first.attempt, 1);
3121
3122        store
3123            .update_run_status(run.id, RunStatus::Running)
3124            .await
3125            .unwrap();
3126        store
3127            .update_run(
3128                run.id,
3129                RunUpdate {
3130                    status: Some(RunStatus::Retrying),
3131                    increment_retry: true,
3132                    ..RunUpdate::default()
3133                },
3134            )
3135            .await
3136            .unwrap();
3137
3138        let second = store
3139            .create_step(new_step_req(run.id, "build", 0))
3140            .await
3141            .unwrap();
3142        assert_eq!(second.attempt, 2);
3143    }
3144
3145    #[tokio::test]
3146    async fn pick_next_pending_ignores_retrying_run_before_its_backoff() {
3147        let store = InMemoryStore::new();
3148        let run = store
3149            .create_run(new_run_req("retry-wf"))
3150            .await
3151            .unwrap()
3152            .into_run();
3153
3154        store
3155            .update_run_status(run.id, RunStatus::Running)
3156            .await
3157            .unwrap();
3158        store
3159            .update_run(
3160                run.id,
3161                RunUpdate {
3162                    status: Some(RunStatus::Retrying),
3163                    increment_retry: true,
3164                    scheduled_at: Some(Utc::now() + TimeDelta::seconds(60)),
3165                    ..RunUpdate::default()
3166                },
3167            )
3168            .await
3169            .unwrap();
3170
3171        assert!(store.pick_next_pending(None).await.unwrap().is_none());
3172    }
3173
3174    #[tokio::test]
3175    async fn pick_next_pending_resumes_retrying_run_after_its_backoff() {
3176        let store = InMemoryStore::new();
3177        let run = store
3178            .create_run(new_run_req("retry-wf"))
3179            .await
3180            .unwrap()
3181            .into_run();
3182
3183        store
3184            .update_run_status(run.id, RunStatus::Running)
3185            .await
3186            .unwrap();
3187        store
3188            .update_run(
3189                run.id,
3190                RunUpdate {
3191                    status: Some(RunStatus::Retrying),
3192                    increment_retry: true,
3193                    scheduled_at: Some(Utc::now() - TimeDelta::seconds(1)),
3194                    ..RunUpdate::default()
3195                },
3196            )
3197            .await
3198            .unwrap();
3199
3200        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
3201        assert_eq!(picked.id, run.id);
3202        assert_eq!(picked.status.state, RunStatus::Running);
3203        assert_eq!(picked.retry_count, 1);
3204    }
3205
3206    #[tokio::test]
3207    async fn update_run_persists_scheduled_at() {
3208        let store = InMemoryStore::new();
3209        let run = store
3210            .create_run(new_run_req("test"))
3211            .await
3212            .unwrap()
3213            .into_run();
3214        let when = Utc::now() + TimeDelta::seconds(30);
3215
3216        store
3217            .update_run(
3218                run.id,
3219                RunUpdate {
3220                    scheduled_at: Some(when),
3221                    ..RunUpdate::default()
3222                },
3223            )
3224            .await
3225            .unwrap();
3226
3227        let fetched = store.get_run(run.id).await.unwrap().unwrap();
3228        assert_eq!(fetched.scheduled_at, Some(when));
3229    }
3230
3231    // ---- output ----
3232
3233    #[tokio::test]
3234    async fn new_run_has_no_output() {
3235        let store = InMemoryStore::new();
3236        let run = store
3237            .create_run(new_run_req("test"))
3238            .await
3239            .unwrap()
3240            .into_run();
3241
3242        assert!(run.output.is_none());
3243        let fetched = store.get_run(run.id).await.unwrap().unwrap();
3244        assert!(fetched.output.is_none());
3245    }
3246
3247    #[tokio::test]
3248    async fn update_run_sets_output() {
3249        let store = InMemoryStore::new();
3250        let run = store
3251            .create_run(new_run_req("test"))
3252            .await
3253            .unwrap()
3254            .into_run();
3255
3256        store
3257            .update_run(
3258                run.id,
3259                RunUpdate {
3260                    output: Some(json!({"verdict": "approved"})),
3261                    ..RunUpdate::default()
3262                },
3263            )
3264            .await
3265            .unwrap();
3266
3267        let fetched = store.get_run(run.id).await.unwrap().unwrap();
3268        assert_eq!(fetched.output, Some(json!({"verdict": "approved"})));
3269    }
3270
3271    #[tokio::test]
3272    async fn update_run_without_output_keeps_previous_output() {
3273        let store = InMemoryStore::new();
3274        let run = store
3275            .create_run(new_run_req("test"))
3276            .await
3277            .unwrap()
3278            .into_run();
3279
3280        store
3281            .update_run(
3282                run.id,
3283                RunUpdate {
3284                    output: Some(json!({"verdict": "approved"})),
3285                    ..RunUpdate::default()
3286                },
3287            )
3288            .await
3289            .unwrap();
3290        store
3291            .update_run(
3292                run.id,
3293                RunUpdate {
3294                    error: Some("boom".to_string()),
3295                    ..RunUpdate::default()
3296                },
3297            )
3298            .await
3299            .unwrap();
3300
3301        let fetched = store.get_run(run.id).await.unwrap().unwrap();
3302        assert_eq!(fetched.output, Some(json!({"verdict": "approved"})));
3303        assert_eq!(fetched.error.as_deref(), Some("boom"));
3304    }
3305
3306    #[tokio::test]
3307    async fn update_run_output_last_write_wins() {
3308        let store = InMemoryStore::new();
3309        let run = store
3310            .create_run(new_run_req("test"))
3311            .await
3312            .unwrap()
3313            .into_run();
3314
3315        for verdict in ["first", "second"] {
3316            store
3317                .update_run(
3318                    run.id,
3319                    RunUpdate {
3320                        output: Some(json!({ "verdict": verdict })),
3321                        ..RunUpdate::default()
3322                    },
3323                )
3324                .await
3325                .unwrap();
3326        }
3327
3328        let fetched = store.get_run(run.id).await.unwrap().unwrap();
3329        assert_eq!(fetched.output, Some(json!({"verdict": "second"})));
3330    }
3331
3332    // ---- created_by ----
3333
3334    async fn seed_user(store: &InMemoryStore, username: &str) -> Uuid {
3335        store
3336            .create_user(NewUser {
3337                email: format!("{username}@example.com"),
3338                username: username.to_string(),
3339                password_hash: "hash".to_string(),
3340                is_admin: Some(false),
3341            })
3342            .await
3343            .unwrap()
3344            .id
3345    }
3346
3347    async fn seed_api_key(store: &InMemoryStore, user_id: Uuid, name: &str) -> Uuid {
3348        store
3349            .create_api_key(NewApiKey {
3350                user_id,
3351                name: name.to_string(),
3352                key_hash: "hash".to_string(),
3353                key_prefix: "irfl_0000".to_string(),
3354                scopes: vec![ApiKeyScope::RunsWrite],
3355                expires_at: None,
3356                rate_limit_override: None,
3357            })
3358            .await
3359            .unwrap()
3360            .id
3361    }
3362
3363    fn run_req_by(actor: RunActor) -> NewRun {
3364        NewRun {
3365            created_by: Some(actor),
3366            ..new_run_req("test")
3367        }
3368    }
3369
3370    #[tokio::test]
3371    async fn create_run_without_actor_has_no_author() {
3372        let store = InMemoryStore::new();
3373        let run = store
3374            .create_run(new_run_req("test"))
3375            .await
3376            .unwrap()
3377            .into_run();
3378
3379        assert!(run.created_by.is_none());
3380        assert!(run.created_by_label.is_none());
3381    }
3382
3383    #[tokio::test]
3384    async fn create_run_by_user_resolves_username_as_label() {
3385        let store = InMemoryStore::new();
3386        let user_id = seed_user(&store, "alice").await;
3387
3388        let run = store
3389            .create_run(run_req_by(RunActor::User { user_id }))
3390            .await
3391            .unwrap()
3392            .into_run();
3393
3394        assert_eq!(run.created_by, Some(RunActor::User { user_id }));
3395        assert_eq!(run.created_by_label.as_deref(), Some("alice"));
3396    }
3397
3398    #[tokio::test]
3399    async fn create_run_by_api_key_resolves_key_and_owner_as_label() {
3400        let store = InMemoryStore::new();
3401        let user_id = seed_user(&store, "alice").await;
3402        let api_key_id = seed_api_key(&store, user_id, "ci-deploy").await;
3403
3404        let run = store
3405            .create_run(run_req_by(RunActor::ApiKey {
3406                api_key_id,
3407                user_id,
3408            }))
3409            .await
3410            .unwrap()
3411            .into_run();
3412
3413        assert_eq!(run.created_by_label.as_deref(), Some("ci-deploy (alice)"));
3414    }
3415
3416    #[tokio::test]
3417    async fn label_follows_api_key_rename() {
3418        let store = InMemoryStore::new();
3419        let user_id = seed_user(&store, "alice").await;
3420        let api_key_id = seed_api_key(&store, user_id, "ci-deploy").await;
3421        let run = store
3422            .create_run(run_req_by(RunActor::ApiKey {
3423                api_key_id,
3424                user_id,
3425            }))
3426            .await
3427            .unwrap()
3428            .into_run();
3429
3430        store
3431            .update_api_key(
3432                api_key_id,
3433                ApiKeyUpdate {
3434                    name: Some("ci-release".to_string()),
3435                    ..ApiKeyUpdate::default()
3436                },
3437            )
3438            .await
3439            .unwrap();
3440
3441        let reread = store.get_run(run.id).await.unwrap().unwrap();
3442        assert_eq!(
3443            reread.created_by_label.as_deref(),
3444            Some("ci-release (alice)")
3445        );
3446    }
3447
3448    #[tokio::test]
3449    async fn label_is_none_when_user_is_unknown() {
3450        let store = InMemoryStore::new();
3451        let run = store
3452            .create_run(run_req_by(RunActor::User {
3453                user_id: Uuid::now_v7(),
3454            }))
3455            .await
3456            .unwrap()
3457            .into_run();
3458
3459        assert!(run.created_by.is_some());
3460        assert!(run.created_by_label.is_none());
3461    }
3462
3463    #[tokio::test]
3464    async fn label_is_key_name_only_when_owner_is_unknown() {
3465        let store = InMemoryStore::new();
3466        let owner = seed_user(&store, "alice").await;
3467        let api_key_id = seed_api_key(&store, owner, "ci-deploy").await;
3468
3469        // An actor pointing at a user that was never created.
3470        let run = store
3471            .create_run(run_req_by(RunActor::ApiKey {
3472                api_key_id,
3473                user_id: Uuid::now_v7(),
3474            }))
3475            .await
3476            .unwrap()
3477            .into_run();
3478
3479        assert_eq!(run.created_by_label.as_deref(), Some("ci-deploy"));
3480    }
3481
3482    #[tokio::test]
3483    async fn list_runs_filters_by_author() {
3484        let store = InMemoryStore::new();
3485        let alice = seed_user(&store, "alice").await;
3486        let bob = seed_user(&store, "bob").await;
3487
3488        store
3489            .create_run(run_req_by(RunActor::User { user_id: alice }))
3490            .await
3491            .unwrap()
3492            .into_run();
3493        store
3494            .create_run(run_req_by(RunActor::User { user_id: bob }))
3495            .await
3496            .unwrap()
3497            .into_run();
3498        store.create_run(new_run_req("anonymous")).await.unwrap();
3499
3500        let page = store
3501            .list_runs(
3502                RunFilter {
3503                    created_by_user_id: Some(alice),
3504                    ..RunFilter::default()
3505                },
3506                1,
3507                20,
3508            )
3509            .await
3510            .unwrap();
3511
3512        assert_eq!(page.total, 1);
3513        assert_eq!(page.items[0].created_by_label.as_deref(), Some("alice"));
3514    }
3515
3516    #[tokio::test]
3517    async fn list_runs_author_filter_matches_runs_from_the_users_api_keys() {
3518        let store = InMemoryStore::new();
3519        let alice = seed_user(&store, "alice").await;
3520        let api_key_id = seed_api_key(&store, alice, "ci-deploy").await;
3521
3522        store
3523            .create_run(run_req_by(RunActor::ApiKey {
3524                api_key_id,
3525                user_id: alice,
3526            }))
3527            .await
3528            .unwrap()
3529            .into_run();
3530
3531        let page = store
3532            .list_runs(
3533                RunFilter {
3534                    created_by_user_id: Some(alice),
3535                    ..RunFilter::default()
3536                },
3537                1,
3538                20,
3539            )
3540            .await
3541            .unwrap();
3542
3543        assert_eq!(page.total, 1);
3544    }
3545
3546    #[tokio::test]
3547    async fn list_runs_author_filter_excludes_unrelated_users() {
3548        let store = InMemoryStore::new();
3549        let alice = seed_user(&store, "alice").await;
3550
3551        store
3552            .create_run(run_req_by(RunActor::User { user_id: alice }))
3553            .await
3554            .unwrap()
3555            .into_run();
3556
3557        let page = store
3558            .list_runs(
3559                RunFilter {
3560                    created_by_user_id: Some(Uuid::now_v7()),
3561                    ..RunFilter::default()
3562                },
3563                1,
3564                20,
3565            )
3566            .await
3567            .unwrap();
3568
3569        assert_eq!(page.total, 0);
3570    }
3571
3572    #[tokio::test]
3573    async fn list_runs_without_author_filter_returns_every_run() {
3574        let store = InMemoryStore::new();
3575        let alice = seed_user(&store, "alice").await;
3576
3577        store
3578            .create_run(run_req_by(RunActor::User { user_id: alice }))
3579            .await
3580            .unwrap()
3581            .into_run();
3582        store.create_run(new_run_req("anonymous")).await.unwrap();
3583
3584        let page = store.list_runs(RunFilter::default(), 1, 20).await.unwrap();
3585        assert_eq!(page.total, 2);
3586    }
3587
3588    #[tokio::test]
3589    async fn pick_next_pending_resolves_author_label() {
3590        let store = InMemoryStore::new();
3591        let user_id = seed_user(&store, "alice").await;
3592        store
3593            .create_run(run_req_by(RunActor::User { user_id }))
3594            .await
3595            .unwrap()
3596            .into_run();
3597
3598        let picked = store.pick_next_pending(None).await.unwrap().unwrap();
3599        assert_eq!(picked.created_by_label.as_deref(), Some("alice"));
3600    }
3601
3602    // ---- list_purgeable_runs ----
3603
3604    #[tokio::test]
3605    async fn list_purgeable_runs_returns_old_terminal_runs() {
3606        let store = InMemoryStore::new();
3607        let old = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
3608        store
3609            .set_run_created_at(old.id, Utc::now() - chrono::Duration::days(100))
3610            .await;
3611
3612        let policy = PurgePolicy {
3613            max_age_days: 90,
3614            max_runs_per_workflow: 10000,
3615            dry_run: false,
3616        };
3617        let result = store.list_purgeable_runs(&policy, 100).await.unwrap();
3618
3619        assert_eq!(result.len(), 1);
3620        assert_eq!(result[0].run_id, old.id);
3621        assert_eq!(result[0].reason, PurgeReason::TooOld);
3622    }
3623
3624    #[tokio::test]
3625    async fn list_purgeable_runs_ignores_non_terminal_states() {
3626        let store = InMemoryStore::new();
3627
3628        // Pending
3629        let pending = store
3630            .create_run(new_run_req("deploy"))
3631            .await
3632            .unwrap()
3633            .into_run();
3634        store
3635            .set_run_created_at(pending.id, Utc::now() - chrono::Duration::days(200))
3636            .await;
3637
3638        // Running
3639        let running = store
3640            .create_run(new_run_req("deploy"))
3641            .await
3642            .unwrap()
3643            .into_run();
3644        store
3645            .update_run_status(running.id, RunStatus::Running)
3646            .await
3647            .unwrap();
3648        store
3649            .set_run_created_at(running.id, Utc::now() - chrono::Duration::days(200))
3650            .await;
3651
3652        let policy = PurgePolicy {
3653            max_age_days: 90,
3654            max_runs_per_workflow: 1,
3655            dry_run: false,
3656        };
3657        let result = store.list_purgeable_runs(&policy, 100).await.unwrap();
3658        assert!(result.is_empty());
3659    }
3660
3661    #[tokio::test]
3662    async fn list_purgeable_runs_returns_excess_per_workflow() {
3663        let store = InMemoryStore::new();
3664        let r1 = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
3665        store
3666            .set_run_created_at(r1.id, Utc::now() - chrono::Duration::days(10))
3667            .await;
3668        let r2 = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
3669        store
3670            .set_run_created_at(r2.id, Utc::now() - chrono::Duration::days(5))
3671            .await;
3672        let _r3 = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
3673
3674        let policy = PurgePolicy {
3675            max_age_days: 365,
3676            max_runs_per_workflow: 2,
3677            dry_run: false,
3678        };
3679        let result = store.list_purgeable_runs(&policy, 100).await.unwrap();
3680
3681        assert_eq!(result.len(), 1);
3682        assert_eq!(result[0].run_id, r1.id);
3683        assert_eq!(result[0].reason, PurgeReason::ExceedsWorkflowLimit);
3684    }
3685
3686    // ---- delete_run ----
3687
3688    #[tokio::test]
3689    async fn delete_run_removes_run_and_associated_data() {
3690        use crate::artifact_store::ArtifactStore;
3691        use crate::entities::{NewStep, StepKind};
3692
3693        let store = InMemoryStore::new();
3694        let run = create_terminal_run(&store, "deploy", RunStatus::Completed).await;
3695        let step = store
3696            .create_step(NewStep {
3697                run_id: run.id,
3698                trace_id: step_trace_id(run.id, "build", 0),
3699                name: "build".to_string(),
3700                kind: StepKind::Shell,
3701                position: 0,
3702                input: None,
3703                is_error_handler: false,
3704            })
3705            .await
3706            .unwrap();
3707
3708        let artifact_id = Uuid::now_v7();
3709        store
3710            .create_artifact(crate::entities::NewArtifact {
3711                id: artifact_id,
3712                run_id: run.id,
3713                step_id: step.id,
3714                name: "report.html".to_string(),
3715                storage_key: format!("artifacts/{}/{}/{}", run.id, step.id, artifact_id),
3716                content_type: "text/html".to_string(),
3717                size_bytes: 42,
3718                sha256: "0".repeat(64),
3719            })
3720            .await
3721            .unwrap();
3722
3723        let keys = store.delete_run(run.id).await.unwrap();
3724
3725        assert_eq!(keys.len(), 1);
3726        assert!(keys[0].contains(&artifact_id.to_string()));
3727        assert!(store.get_run(run.id).await.unwrap().is_none());
3728        assert!(store.list_steps(run.id).await.unwrap().is_empty());
3729        assert!(
3730            store
3731                .list_artifacts_for_run(run.id)
3732                .await
3733                .unwrap()
3734                .is_empty()
3735        );
3736    }
3737
3738    #[tokio::test]
3739    async fn delete_run_not_found() {
3740        let store = InMemoryStore::new();
3741        let err = store.delete_run(Uuid::now_v7()).await.unwrap_err();
3742        assert!(matches!(err, StoreError::RunNotFound(_)));
3743    }
3744
3745    // ---- record_step_approval ----
3746
3747    fn vote(user_id: Uuid, name: &str) -> StepApproval {
3748        StepApproval {
3749            user_id,
3750            approved_by: name.to_string(),
3751            at: Utc::now(),
3752        }
3753    }
3754
3755    #[tokio::test]
3756    async fn record_step_approval_appends_distinct_voters() {
3757        let store = InMemoryStore::new();
3758        let run = store
3759            .create_run(new_run_req("test"))
3760            .await
3761            .unwrap()
3762            .into_run();
3763        let step = store
3764            .create_step(new_step_req(run.id, "gate", 0))
3765            .await
3766            .unwrap();
3767        assert!(step.approvals.is_empty());
3768        assert!(step.approval_requirement.is_none());
3769
3770        let alice = Uuid::now_v7();
3771        let bob = Uuid::now_v7();
3772        let after_first = store
3773            .record_step_approval(step.id, vote(alice, "alice"))
3774            .await
3775            .unwrap();
3776        assert_eq!(after_first.approvals.len(), 1);
3777
3778        let after_second = store
3779            .record_step_approval(step.id, vote(bob, "bob"))
3780            .await
3781            .unwrap();
3782        assert_eq!(after_second.approvals.len(), 2);
3783        assert_eq!(after_second.approvals[0].user_id, alice);
3784        assert_eq!(after_second.approvals[1].user_id, bob);
3785    }
3786
3787    #[tokio::test]
3788    async fn record_step_approval_ignores_same_user() {
3789        let store = InMemoryStore::new();
3790        let run = store
3791            .create_run(new_run_req("test"))
3792            .await
3793            .unwrap()
3794            .into_run();
3795        let step = store
3796            .create_step(new_step_req(run.id, "gate", 0))
3797            .await
3798            .unwrap();
3799
3800        let alice = Uuid::now_v7();
3801        store
3802            .record_step_approval(step.id, vote(alice, "alice"))
3803            .await
3804            .unwrap();
3805        let again = store
3806            .record_step_approval(step.id, vote(alice, "alice-key"))
3807            .await
3808            .unwrap();
3809
3810        assert_eq!(again.approvals.len(), 1);
3811        assert_eq!(again.approvals[0].approved_by, "alice");
3812    }
3813
3814    #[tokio::test]
3815    async fn record_step_approval_unknown_step_is_not_found() {
3816        let store = InMemoryStore::new();
3817        let err = store
3818            .record_step_approval(Uuid::now_v7(), vote(Uuid::now_v7(), "alice"))
3819            .await
3820            .unwrap_err();
3821        assert!(matches!(err, StoreError::StepNotFound(_)));
3822    }
3823
3824    #[tokio::test]
3825    async fn update_step_sets_approval_requirement() {
3826        let store = InMemoryStore::new();
3827        let run = store
3828            .create_run(new_run_req("test"))
3829            .await
3830            .unwrap()
3831            .into_run();
3832        let step = store
3833            .create_step(new_step_req(run.id, "gate", 0))
3834            .await
3835            .unwrap();
3836        let requirement = ApprovalRequirement {
3837            required_approvers: 3,
3838            ..ApprovalRequirement::default()
3839        };
3840
3841        store
3842            .update_step(
3843                step.id,
3844                StepUpdate {
3845                    approval_requirement: Some(requirement.clone()),
3846                    ..StepUpdate::default()
3847                },
3848            )
3849            .await
3850            .unwrap();
3851
3852        let fetched = store.get_step(step.id).await.unwrap().unwrap();
3853        assert_eq!(fetched.approval_requirement, Some(requirement));
3854    }
3855
3856    async fn sleeping_run(store: &InMemoryStore, scheduled_at: DateTime<Utc>) -> Run {
3857        let run = store
3858            .create_run(new_run_req("sleepy"))
3859            .await
3860            .unwrap()
3861            .into_run();
3862        store
3863            .update_run_status(run.id, RunStatus::Running)
3864            .await
3865            .unwrap();
3866        store
3867            .update_run(
3868                run.id,
3869                RunUpdate {
3870                    status: Some(RunStatus::Sleeping),
3871                    scheduled_at: Some(scheduled_at),
3872                    ..RunUpdate::default()
3873                },
3874            )
3875            .await
3876            .unwrap();
3877        store.get_run(run.id).await.unwrap().unwrap()
3878    }
3879
3880    #[tokio::test]
3881    async fn claim_due_sleeping_runs_requeues_due_runs() {
3882        let store = InMemoryStore::new();
3883        let due = sleeping_run(&store, Utc::now() - TimeDelta::seconds(5)).await;
3884
3885        let woken = store.claim_due_sleeping_runs(10).await.unwrap();
3886        assert_eq!(woken.len(), 1);
3887        assert_eq!(woken[0].id, due.id);
3888        assert_eq!(woken[0].status.state, RunStatus::Pending);
3889        assert!(woken[0].scheduled_at.is_none());
3890
3891        let fetched = store.get_run(due.id).await.unwrap().unwrap();
3892        assert_eq!(fetched.status.state, RunStatus::Pending);
3893        assert!(fetched.scheduled_at.is_none());
3894
3895        // Exactly once: a second tick finds nothing.
3896        assert!(store.claim_due_sleeping_runs(10).await.unwrap().is_empty());
3897    }
3898
3899    #[tokio::test]
3900    async fn claim_due_sleeping_runs_skips_future_runs() {
3901        let store = InMemoryStore::new();
3902        let future = sleeping_run(&store, Utc::now() + TimeDelta::hours(1)).await;
3903
3904        assert!(store.claim_due_sleeping_runs(10).await.unwrap().is_empty());
3905        let fetched = store.get_run(future.id).await.unwrap().unwrap();
3906        assert_eq!(fetched.status.state, RunStatus::Sleeping);
3907    }
3908
3909    #[tokio::test]
3910    async fn claim_due_sleeping_runs_honours_limit_oldest_first() {
3911        let store = InMemoryStore::new();
3912        let older = sleeping_run(&store, Utc::now() - TimeDelta::seconds(20)).await;
3913        let newer = sleeping_run(&store, Utc::now() - TimeDelta::seconds(10)).await;
3914
3915        let woken = store.claim_due_sleeping_runs(1).await.unwrap();
3916        assert_eq!(woken.len(), 1);
3917        assert_eq!(woken[0].id, older.id);
3918
3919        let woken = store.claim_due_sleeping_runs(1).await.unwrap();
3920        assert_eq!(woken[0].id, newer.id);
3921    }
3922}