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
19fn 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
43fn 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
51fn 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 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 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 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 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 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 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 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 state.artifacts.retain(|_, a| a.run_id != id);
571
572 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 state.steps.retain(|_, s| s.run_id != id);
585
586 state.idempotency_keys.retain(|_, &mut run_id| run_id != id);
588
589 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[tokio::test]
1860 async fn concurrent_pick_next_pending_no_double_pick() {
1861 let store = InMemoryStore::new();
1862
1863 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 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 let unique: std::collections::HashSet<_> = picked_ids.iter().collect();
1888 assert_eq!(unique.len(), picked_ids.len());
1889 }
1890
1891 #[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 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 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 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 store
1954 .update_run_status(r3.id, RunStatus::Cancelled)
1955 .await
1956 .unwrap();
1957
1958 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); 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()); }
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 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 let result = store
2059 .update_run(
2060 run.id,
2061 RunUpdate {
2062 status: Some(RunStatus::Completed), ..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 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 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 store
2241 .update_run_status(r2.id, RunStatus::Running)
2242 .await
2243 .unwrap();
2244
2245 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 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 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 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 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(); 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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 #[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 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 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 #[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 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 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}