Skip to main content

eoka_server/operations/
mod.rs

1pub mod commands;
2pub mod sidecar;
3pub(crate) mod store;
4
5use anyhow::{anyhow, Context, Result};
6use eoka::{Action, ActionOptions, ActionOutcome, DispatchObserver, Error, Page};
7use eoka_protocol::{OperationListArgs, OperationRecord, OperationState};
8use std::{
9    collections::HashMap,
10    future::Future,
11    path::PathBuf,
12    pin::Pin,
13    sync::{Arc, Mutex},
14    time::Duration,
15};
16use store::{timestamp, Store};
17use tokio::sync::{watch, OnceCell};
18
19#[derive(Clone, Default)]
20pub struct OperationManager {
21    inner: Arc<Manager>,
22}
23#[derive(Default)]
24struct Manager {
25    store: OnceCell<Arc<Store>>,
26    directory: Option<PathBuf>,
27    jobs: Mutex<HashMap<String, watch::Sender<bool>>>,
28}
29
30#[derive(Default)]
31pub(crate) struct Execution {
32    pub canonical: Option<Vec<u8>>,
33    pub exploratory: bool,
34}
35
36pub(crate) struct Intent {
37    pub store: Arc<Store>,
38    pub id: String,
39}
40impl DispatchObserver for Intent {
41    fn before_dispatch(
42        &self,
43        preparation: bool,
44    ) -> Pin<Box<dyn Future<Output = eoka::Result<()>> + Send + '_>> {
45        Box::pin(async move {
46            self.store
47                .update(self.id.clone(), move |record| {
48                    if preparation {
49                        record.preparation_intent = true;
50                    } else {
51                        record.dispatch_intent = true;
52                        record.outcome = ActionOutcome::OutcomeUnknown;
53                    }
54                })
55                .await
56                .and_then(|record| {
57                    if matches!(record.state, OperationState::Running)
58                        && record.runtime_id == self.store.runtime
59                        && (if preparation {
60                            record.preparation_intent
61                        } else {
62                            record.dispatch_intent
63                        })
64                    {
65                        Ok(())
66                    } else {
67                        Err(anyhow!("Operation is no longer owned and running"))
68                    }
69                })
70                .map_err(|error| {
71                    Error::transport_io(
72                        "Could not durably record dispatch intent",
73                        std::io::Error::other(error),
74                    )
75                })
76        })
77    }
78}
79
80impl OperationManager {
81    pub fn in_directory(directory: PathBuf) -> Self {
82        Self {
83            inner: Arc::new(Manager {
84                directory: Some(directory),
85                ..Default::default()
86            }),
87        }
88    }
89    pub(crate) async fn store(&self) -> Result<&Arc<Store>> {
90        self.inner
91            .store
92            .get_or_try_init(|| async {
93                Store::open(match &self.inner.directory {
94                    Some(path) => path.clone(),
95                    None => store::state_directory()?,
96                })
97                .await
98            })
99            .await
100    }
101    pub async fn submit(
102        &self,
103        page: Page,
104        id: String,
105        action: Action,
106        options: ActionOptions,
107    ) -> Result<OperationRecord> {
108        self.submit_with(page, id, action, options, Execution::default())
109            .await
110    }
111
112    pub(crate) async fn submit_with(
113        &self,
114        page: Page,
115        id: String,
116        action: Action,
117        options: ActionOptions,
118        execution: Execution,
119    ) -> Result<OperationRecord> {
120        let manager = self.clone();
121        tokio::spawn(async move {
122            manager
123                .submit_owned(page, id, action, options, execution)
124                .await
125        })
126        .await
127        .context("Operation admission task failed; inspect its ID before retrying")?
128    }
129
130    async fn submit_owned(
131        &self,
132        page: Page,
133        id: String,
134        action: Action,
135        mut options: ActionOptions,
136        execution: Execution,
137    ) -> Result<OperationRecord> {
138        let parsed = uuid::Uuid::parse_str(&id)
139            .context("operation_id must be a UUID generated before sending the request")?;
140        if parsed.to_string() != id {
141            return Err(anyhow!("operation_id must be a canonical lowercase UUID"));
142        }
143        let store = self.store().await?.clone();
144        let canonical = match execution.canonical {
145            Some(canonical) => canonical,
146            None => serde_json::to_vec(&serde_json::json!({"action":action,"options":options}))?,
147        };
148        let initial = OperationRecord {
149            id: id.clone(),
150            runtime_id: store.runtime.clone(),
151            target_id: page.target_id().into(),
152            action: action.kind().into(),
153            state: OperationState::Running,
154            outcome: ActionOutcome::NotDispatched,
155            dispatch_intent: false,
156            preparation_intent: false,
157            started_at_ms: timestamp(),
158            finished_at_ms: None,
159            receipt: None,
160            error_code: None,
161        };
162        let (record, created) = store.admit(initial, canonical).await?;
163        if !created {
164            return Ok(record);
165        }
166        let (cancel, mut canceled) = watch::channel(false);
167        self.inner
168            .jobs
169            .lock()
170            .map_err(|_| anyhow!("Operation registry unavailable"))?
171            .insert(id.clone(), cancel);
172        options.observer = Some(Arc::new(Intent {
173            store: store.clone(),
174            id: id.clone(),
175        }));
176        let manager = self.clone();
177        tokio::spawn(async move {
178            let result = tokio::select! {
179                biased;
180                _ = canceled.changed() => None,
181                result = async {
182                    if execution.exploratory { page.explore(action, options).await }
183                    else { page.act(action, options).await }
184                } => Some(result),
185            };
186            let finish = store
187                .update(id.clone(), move |record| {
188                    record.state = OperationState::Finished;
189                    record.finished_at_ms = Some(timestamp());
190                    match result {
191                        Some(Ok(receipt)) => {
192                            record.outcome = receipt.outcome;
193                            record.receipt = Some(receipt);
194                        }
195                        Some(Err(Error::Action { receipt, code, .. })) => {
196                            record.outcome = receipt.outcome;
197                            record.receipt = Some(*receipt);
198                            record.error_code = Some(code);
199                        }
200                        other => {
201                            record.outcome = if record.dispatch_intent {
202                                ActionOutcome::OutcomeUnknown
203                            } else {
204                                ActionOutcome::NotDispatched
205                            };
206                            record.error_code = Some(
207                                if other.is_none() {
208                                    "operation_canceled"
209                                } else {
210                                    "action_failed"
211                                }
212                                .into(),
213                            );
214                        }
215                    }
216                })
217                .await;
218            if let Err(error) = finish {
219                tracing::error!(operation_id = %id, %error, "Could not persist operation completion; outcome remains uncertain");
220            }
221            if let Ok(mut jobs) = manager.inner.jobs.lock() {
222                jobs.remove(&id);
223            }
224        });
225        Ok(record)
226    }
227    pub async fn get(&self, id: &str) -> Result<OperationRecord> {
228        let store = self.store().await?;
229        store.recover().await?;
230        store.get(id.into()).await
231    }
232    pub async fn list(&self, args: OperationListArgs) -> Result<Vec<OperationRecord>> {
233        let limit = args.limit.unwrap_or(100).clamp(1, 500);
234        let before = args.before_ms.unwrap_or(i64::MAX as u64);
235        self.store()
236            .await?
237            .run(move |connection| {
238                let mut query = connection.prepare(
239                    "SELECT record FROM operations WHERE started < ?
240                     ORDER BY started DESC,id LIMIT ?",
241                )?;
242                let rows = query.query_map(rusqlite::params![before, limit], |row| {
243                    row.get::<_, String>(0)
244                })?;
245                rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect()
246            })
247            .await
248    }
249    pub async fn wait(&self, id: &str, timeout_ms: u64) -> Result<OperationRecord> {
250        if timeout_ms > 86_400_000 {
251            return Err(anyhow!("Wait timeout exceeds 24 hours"));
252        }
253        let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
254        loop {
255            let record = self.get(id).await?;
256            if !matches!(record.state, OperationState::Running)
257                || tokio::time::Instant::now() >= deadline
258            {
259                return Ok(record);
260            }
261            tokio::time::sleep_until(
262                deadline.min(tokio::time::Instant::now() + Duration::from_millis(25)),
263            )
264            .await;
265        }
266    }
267    pub async fn cancel(&self, id: &str) -> Result<OperationRecord> {
268        let record = self.get(id).await?;
269        self.cancel_admitted(id, record)
270    }
271
272    pub(crate) fn cancel_admitted(
273        &self,
274        id: &str,
275        record: OperationRecord,
276    ) -> Result<OperationRecord> {
277        if matches!(record.state, OperationState::Running) {
278            let jobs = self
279                .inner
280                .jobs
281                .lock()
282                .map_err(|_| anyhow!("Operation registry unavailable"))?;
283            let sender = jobs.get(id).ok_or_else(|| {
284                anyhow!("operation_owned_elsewhere: request cancellation from the owning runtime")
285            })?;
286            sender.send_replace(true);
287        }
288        Ok(record)
289    }
290    pub async fn stop(&self) {
291        let ids = if let Ok(jobs) = self.inner.jobs.lock() {
292            jobs.iter()
293                .map(|(id, cancel)| {
294                    cancel.send_replace(true);
295                    id.clone()
296                })
297                .collect::<Vec<_>>()
298        } else {
299            Vec::new()
300        };
301        for id in ids {
302            if let Err(error) = self.wait(&id, 5000).await {
303                tracing::warn!(operation_id = %id, %error, "Could not confirm operation shutdown");
304            }
305        }
306    }
307    pub fn is_active(&self) -> bool {
308        self.inner
309            .jobs
310            .lock()
311            .map(|j| !j.is_empty())
312            .unwrap_or(true)
313    }
314}
315
316#[cfg(test)]
317mod tests;