Skip to main content

a3s_flow/engine/
inspection.rs

1use chrono::{DateTime, Utc};
2
3use crate::error::{FlowError, Result};
4use crate::model::{
5    project_run, ActiveHookSnapshot, HookStatus, ScheduledWakeup, ScheduledWakeupKind, StepStatus,
6    WaitStatus, WorkflowRunSnapshot, WorkflowRunSummary, WorkflowRunSuspension,
7};
8
9use super::FlowEngine;
10
11impl FlowEngine {
12    /// Project the current snapshot for `run_id` from its durable history.
13    pub async fn snapshot(&self, run_id: &str) -> Result<WorkflowRunSnapshot> {
14        let history = self.store.list(run_id).await?;
15        project_run(run_id, &history)
16    }
17
18    /// Load the complete durable event history for `run_id`.
19    pub async fn history(&self, run_id: &str) -> Result<Vec<crate::model::FlowEventEnvelope>> {
20        self.store.list(run_id).await
21    }
22
23    /// List all workflow run IDs known to the engine's store.
24    pub async fn list_run_ids(&self) -> Result<Vec<String>> {
25        self.store.list_run_ids().await
26    }
27
28    /// Project current snapshots for every workflow run in the store.
29    pub async fn list_snapshots(&self) -> Result<Vec<WorkflowRunSnapshot>> {
30        let mut snapshots = Vec::new();
31        for run_id in self.store.list_run_ids().await? {
32            snapshots.push(self.snapshot(&run_id).await?);
33        }
34        Ok(snapshots)
35    }
36
37    /// Summarize run state across the active store.
38    ///
39    /// Suspension counters include only non-terminal runs, so a cancelled run
40    /// that still has old suspension history is not reported as actionable.
41    pub async fn run_summary(&self) -> Result<WorkflowRunSummary> {
42        let snapshots = self.list_snapshots().await?;
43        Ok(WorkflowRunSummary::from_snapshots(&snapshots))
44    }
45
46    /// List open waits, active hooks, signal waits, delayed retries, and child runs.
47    ///
48    /// The `due` flag on wait and retry suspensions is computed against `now`.
49    /// Terminal runs are skipped so cancelled histories do not produce
50    /// actionable operator work.
51    pub async fn list_open_suspensions(
52        &self,
53        now: DateTime<Utc>,
54    ) -> Result<Vec<WorkflowRunSuspension>> {
55        let mut suspensions = Vec::new();
56        for run_id in self.store.list_run_ids().await? {
57            let snapshot = self.snapshot(&run_id).await?;
58            if snapshot.status.is_terminal() {
59                continue;
60            }
61            for wait in snapshot.waits.values() {
62                if wait.status == WaitStatus::Waiting {
63                    suspensions.push(WorkflowRunSuspension::Wait {
64                        run_id: run_id.clone(),
65                        wait: wait.clone(),
66                        due: wait.resume_at <= now,
67                    });
68                }
69            }
70            for hook in snapshot.hooks.values() {
71                if hook.status == HookStatus::Active {
72                    suspensions.push(WorkflowRunSuspension::Hook {
73                        run_id: run_id.clone(),
74                        hook: hook.clone(),
75                    });
76                }
77            }
78            for step in snapshot.steps.values() {
79                if step.status == StepStatus::Pending {
80                    if let Some(retry_after) = step.retry_after {
81                        suspensions.push(WorkflowRunSuspension::Retry {
82                            run_id: run_id.clone(),
83                            step: step.clone(),
84                            due: retry_after <= now,
85                        });
86                    }
87                }
88            }
89            for child in snapshot.child_workflows.values() {
90                if child.is_open() {
91                    suspensions.push(WorkflowRunSuspension::ChildWorkflow {
92                        run_id: run_id.clone(),
93                        child: child.clone(),
94                    });
95                }
96            }
97            for wait in snapshot.signal_waits.values() {
98                if wait.status == crate::model::SignalWaitStatus::Waiting {
99                    suspensions.push(WorkflowRunSuspension::Signal {
100                        run_id: run_id.clone(),
101                        wait: wait.clone(),
102                    });
103                }
104            }
105        }
106        suspensions.sort_by(|left, right| {
107            (left.run_id(), left.kind_order(), left.subject_id()).cmp(&(
108                right.run_id(),
109                right.kind_order(),
110                right.subject_id(),
111            ))
112        });
113        Ok(suspensions)
114    }
115
116    /// Return the earliest open wait or delayed retry across non-terminal runs.
117    ///
118    /// Active hooks and signal waits are intentionally ignored because they do
119    /// not have a scheduled wake-up time.
120    pub async fn next_wakeup(&self, now: DateTime<Utc>) -> Result<Option<WorkflowRunSuspension>> {
121        for _ in 0..2 {
122            let Some(wakeup) = self.store.next_scheduled_wakeup().await? else {
123                return Ok(None);
124            };
125            match self.snapshot(&wakeup.run_id).await {
126                Ok(snapshot) => {
127                    if let Some(suspension) = resolve_scheduled_wakeup(&snapshot, &wakeup, now) {
128                        return Ok(Some(suspension));
129                    }
130                }
131                Err(FlowError::RunNotFound(_)) => {}
132                Err(error) => return Err(error),
133            }
134        }
135
136        self.next_wakeup_by_replay(now).await
137    }
138
139    async fn next_wakeup_by_replay(
140        &self,
141        now: DateTime<Utc>,
142    ) -> Result<Option<WorkflowRunSuspension>> {
143        let mut wakeups = self.list_open_suspensions(now).await?;
144        wakeups.retain(|suspension| suspension.scheduled_at().is_some());
145        wakeups.sort_by(|left, right| {
146            (
147                left.scheduled_at(),
148                left.run_id(),
149                left.kind_order(),
150                left.subject_id(),
151            )
152                .cmp(&(
153                    right.scheduled_at(),
154                    right.run_id(),
155                    right.kind_order(),
156                    right.subject_id(),
157                ))
158        });
159        Ok(wakeups.into_iter().next())
160    }
161
162    /// List active external callback hooks across non-terminal runs.
163    pub async fn list_active_hooks(&self) -> Result<Vec<ActiveHookSnapshot>> {
164        self.store.list_active_hooks().await
165    }
166}
167
168fn resolve_scheduled_wakeup(
169    snapshot: &WorkflowRunSnapshot,
170    wakeup: &ScheduledWakeup,
171    now: DateTime<Utc>,
172) -> Option<WorkflowRunSuspension> {
173    if snapshot.run_id != wakeup.run_id || snapshot.status.is_terminal() {
174        return None;
175    }
176    match wakeup.kind {
177        ScheduledWakeupKind::Wait => {
178            let wait = snapshot.waits.get(&wakeup.subject_id)?;
179            if wait.status != WaitStatus::Waiting || wait.resume_at != wakeup.scheduled_at {
180                return None;
181            }
182            Some(WorkflowRunSuspension::Wait {
183                run_id: wakeup.run_id.clone(),
184                wait: wait.clone(),
185                due: wakeup.scheduled_at <= now,
186            })
187        }
188        ScheduledWakeupKind::Retry => {
189            let step = snapshot.steps.get(&wakeup.subject_id)?;
190            if step.status != StepStatus::Pending || step.retry_after != Some(wakeup.scheduled_at) {
191                return None;
192            }
193            Some(WorkflowRunSuspension::Retry {
194                run_id: wakeup.run_id.clone(),
195                step: step.clone(),
196                due: wakeup.scheduled_at <= now,
197            })
198        }
199    }
200}