Skip to main content

openquiver_runtime/
lib.rs

1//! Running a workflow, durably.
2//!
3//! A run's state *is* its journal. Nothing is held anywhere else: every node
4//! writes what it is about to do before doing it, and what it produced
5//! afterwards, and resuming is a walk over those records back to the position
6//! the run was in. That inverts the usual role of a log — here it is the
7//! authority rather than a commentary — and it is what lets a run outlive the
8//! process, the terminal and the machine's next reboot.
9//!
10//! Long waits are the normal case rather than the exception. An agent turn takes
11//! minutes, an approval can take days, and a run that dies with its window would
12//! be useless for either.
13
14mod journal;
15mod nodes;
16mod replay;
17mod value;
18
19use std::collections::BTreeMap;
20
21use openquiver_acp::{AcpError, Agents};
22use openquiver_compile::{CompiledPlan, Source};
23use openquiver_graph::{NodeName, PinRef};
24use openquiver_nodes::Recovery;
25use openquiver_store::{RunId, RunStatus, Store, StoreError};
26
27use journal::{Entered, Exited, Record, Started};
28use nodes::Effects;
29use replay::Frontier;
30
31pub use journal::JournalError;
32pub use value::RunValue;
33
34/// What one step of a run did.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Progress {
37    Ran(NodeName),
38    /// Execution reached the end and the run was closed.
39    Done,
40}
41
42/// What to do about a node that was entered but never left.
43///
44/// The one question a journal cannot answer about itself. Between "it was
45/// entered" and "it exited" the node may have created an issue, written a file
46/// or paid for an agent turn — and it may have done none of that. Nothing
47/// recorded afterwards settles it.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum InDoubt {
50    /// Refuse to guess, and say which node is in question.
51    Ask,
52    /// Run it again, accepting that its effect may happen twice.
53    Retry,
54}
55
56#[derive(Debug, thiserror::Error)]
57pub enum RuntimeError {
58    #[error("the journal could not be reached: {0}")]
59    Store(#[from] StoreError),
60    #[error(transparent)]
61    Agent(#[from] AcpError),
62    #[error(transparent)]
63    Journal(#[from] JournalError),
64    #[error("a journal entry could not be written: {0}")]
65    Unwritable(#[from] serde_json::Error),
66    #[error("this workflow has no starting event, so there is nothing to run")]
67    NoEntry,
68    #[error("this workflow has {0} starting events, and a run begins at one")]
69    ManyEntries(usize),
70    #[error("run `{0}` has already finished")]
71    AlreadyFinished(String),
72    #[error("this journal cannot be replayed: {0}")]
73    BadJournal(&'static str),
74    #[error(
75        "this run was started on a different version of this workflow; \
76         the graph has been edited since"
77    )]
78    DifferentPlan,
79    #[error("this journal mentions `{0}`, which is not a node in this workflow")]
80    NotInPlan(String),
81    #[error("there is no way to run a `{0}` node")]
82    NoSuchNodeType(String),
83    #[error("`{node}` needs `{pin}`, but `{from}` has not run and cannot supply it")]
84    NotYetRun {
85        node: String,
86        pin: String,
87        from: String,
88    },
89    #[error("`{node}` needs `{pin}`, and nothing supplies it")]
90    NoInput { node: String, pin: String },
91    #[error("`{node}` expects {expected} on `{pin}`")]
92    WrongValue {
93        node: String,
94        pin: String,
95        expected: &'static str,
96    },
97    #[error(
98        "`{node}` was entered and never left, so whether it finished is unknown; \
99         resume again saying whether to run it"
100    )]
101    InDoubt { node: String },
102}
103
104impl RuntimeError {
105    /// Whether resuming could ever get past this.
106    ///
107    /// A run stopped by something that will fail identically next time is
108    /// finished, not waiting. Recording it as merely suspended puts it in the
109    /// list of runs to pick up, where every attempt appends another
110    /// `node.entered` to a journal that will never reach an end — and makes a
111    /// permanently broken run indistinguishable from one waiting on a person.
112    ///
113    /// Matched exhaustively on purpose: a new way to fail must be classified,
114    /// not inherit a default.
115    fn is_permanent(&self) -> bool {
116        match self {
117            // The disk, the agent process and the network can all be tried
118            // again, and trying again is exactly what resuming is for.
119            RuntimeError::Store(_) | RuntimeError::Agent(_) => false,
120            RuntimeError::Journal(_)
121            | RuntimeError::Unwritable(_)
122            | RuntimeError::NoEntry
123            | RuntimeError::ManyEntries(_)
124            | RuntimeError::AlreadyFinished(_)
125            | RuntimeError::BadJournal(_)
126            | RuntimeError::DifferentPlan
127            | RuntimeError::NotInPlan(_)
128            | RuntimeError::NoSuchNodeType(_)
129            | RuntimeError::NotYetRun { .. }
130            | RuntimeError::NoInput { .. }
131            | RuntimeError::WrongValue { .. } => true,
132            // Not raised by a running node — it is the answer `resume` gives
133            // before a run is under way — so it never reaches this.
134            RuntimeError::InDoubt { .. } => true,
135        }
136    }
137}
138
139/// One run in progress.
140pub struct Runtime<'a> {
141    store: &'a Store,
142    plan: &'a CompiledPlan,
143    run: RunId,
144    effects: Effects<'a>,
145    values: BTreeMap<PinRef, RunValue>,
146    next: Option<NodeName>,
147    finished: bool,
148}
149
150impl<'a> Runtime<'a> {
151    /// Starts a new run and records that it started.
152    pub async fn start(
153        store: &'a Store,
154        plan: &'a CompiledPlan,
155        agents: &'a dyn Agents,
156        workflow: &str,
157    ) -> Result<Self, RuntimeError> {
158        let entry = single_entry(plan)?;
159        let run = store.start_run(workflow).await?;
160
161        let runtime = Self {
162            store,
163            plan,
164            run,
165            effects: Effects {
166                agents,
167                sessions: BTreeMap::new(),
168            },
169            values: BTreeMap::new(),
170            next: Some(entry),
171            finished: false,
172        };
173        runtime
174            .append(Record::Started(Started {
175                plan: plan.fingerprint(),
176            }))
177            .await?;
178        Ok(runtime)
179    }
180
181    /// Picks up a run from its journal.
182    ///
183    /// Nodes that recorded an exit are not run again; their outputs are read
184    /// back from what they wrote. `in_doubt` answers the one case that cannot be
185    /// settled from the record — and it is only consulted for node types that
186    /// say repeating them is unsafe.
187    pub async fn resume(
188        store: &'a Store,
189        plan: &'a CompiledPlan,
190        agents: &'a dyn Agents,
191        run: RunId,
192        in_doubt: InDoubt,
193    ) -> Result<Self, RuntimeError> {
194        if !store.status(&run).await?.resumable() {
195            return Err(RuntimeError::AlreadyFinished(run.as_str().to_owned()));
196        }
197
198        let entry = single_entry(plan)?;
199        let events = store.events(&run).await?;
200        let replayed = replay::replay(&events, plan, &entry)?;
201
202        let next = match replayed.frontier {
203            Frontier::End => None,
204            Frontier::At(node) => Some(node),
205            Frontier::InDoubt(node) => {
206                let recovery = plan
207                    .node(node.as_str())
208                    .ok_or_else(|| RuntimeError::NotInPlan(node.as_str().to_owned()))?
209                    .recovery;
210                match (recovery, in_doubt) {
211                    (Recovery::Retry, _) | (Recovery::Ask, InDoubt::Retry) => Some(node),
212                    (Recovery::Ask, InDoubt::Ask) => {
213                        return Err(RuntimeError::InDoubt {
214                            node: node.as_str().to_owned(),
215                        });
216                    }
217                }
218            }
219        };
220
221        store.resume(&run).await?;
222        Ok(Self {
223            store,
224            plan,
225            run,
226            effects: Effects {
227                agents,
228                sessions: BTreeMap::new(),
229            },
230            values: replayed.values,
231            next,
232            finished: replayed.finished,
233        })
234    }
235
236    pub fn id(&self) -> &RunId {
237        &self.run
238    }
239
240    /// Runs to the end, or to the first thing that stops it.
241    pub async fn run(&mut self) -> Result<(), RuntimeError> {
242        while let Progress::Ran(_) = self.step().await? {}
243        Ok(())
244    }
245
246    /// Runs one node.
247    ///
248    /// The unit a run is killed and resumed at, and the unit a debugger steps
249    /// by — the same primitive, because a breakpoint and a crash leave the
250    /// journal in exactly the same shape.
251    pub async fn step(&mut self) -> Result<Progress, RuntimeError> {
252        let Some(name) = self.next.clone() else {
253            self.close().await?;
254            return Ok(Progress::Done);
255        };
256
257        match self.run_node(&name).await {
258            Ok(()) => Ok(Progress::Ran(name)),
259            Err(error) => {
260                // The journal is left exactly as it is: the entry with no exit
261                // is the evidence that this node is in doubt, and erasing it
262                // would turn an honest uncertainty into a silent repeat.
263                let recorded = if error.is_permanent() {
264                    self.store.finish(&self.run, RunStatus::Failed).await
265                } else {
266                    self.store.suspend(&self.run).await
267                };
268                if let Err(problem) = recorded {
269                    tracing::warn!(
270                        run = self.run.as_str(),
271                        "could not record how the run stopped: {problem}"
272                    );
273                }
274                Err(error)
275            }
276        }
277    }
278
279    async fn run_node(&mut self, name: &NodeName) -> Result<(), RuntimeError> {
280        let node = self
281            .plan
282            .node(name.as_str())
283            .ok_or_else(|| RuntimeError::NotInPlan(name.as_str().to_owned()))?;
284        // Before the entry, not after: resolving inputs touches nothing, so a
285        // graph that cannot supply a pin should not leave a node looking as if
286        // it might have run.
287        let inputs = self.resolve(name, node)?;
288
289        self.append(Record::Entered(Entered { node: name.clone() }))
290            .await?;
291        let outcome = nodes::perform(&mut self.effects, name, node, &inputs).await?;
292
293        for record in outcome.records {
294            self.append(record).await?;
295        }
296        self.append(Record::Exited(Exited {
297            node: name.clone(),
298            outputs: outcome.outputs.clone(),
299            exec: outcome.exec.map(str::to_owned),
300        }))
301        .await?;
302
303        for (pin, value) in outcome.outputs {
304            self.values.insert(PinRef::new(name.clone(), pin), value);
305        }
306        self.next = outcome
307            .exec
308            .and_then(|pin| node.exec_next.get(pin).cloned());
309        Ok(())
310    }
311
312    fn resolve(
313        &self,
314        name: &NodeName,
315        node: &openquiver_compile::CompiledNode,
316    ) -> Result<BTreeMap<String, RunValue>, RuntimeError> {
317        node.inputs
318            .iter()
319            .map(|(pin, source)| {
320                let value =
321                    match source {
322                        Source::Literal(literal) => RunValue::from(literal),
323                        Source::Pin(from) => self.values.get(from).cloned().ok_or_else(|| {
324                            RuntimeError::NotYetRun {
325                                node: name.as_str().to_owned(),
326                                pin: pin.clone(),
327                                from: from.node().to_owned(),
328                            }
329                        })?,
330                    };
331                Ok((pin.clone(), value))
332            })
333            .collect()
334    }
335
336    /// Records the end of the run.
337    ///
338    /// The record is written once; the status is reconciled every time. They are
339    /// two writes, and a run killed between them left the journal saying it had
340    /// ended and the run row still saying `Running` — resumable for ever, picked
341    /// up by every startup, and doing nothing each time.
342    async fn close(&mut self) -> Result<(), RuntimeError> {
343        if !self.finished {
344            // In this order: the journal refuses entries to a finished run, and
345            // it is the journal that says the run finished.
346            self.append(Record::Finished).await?;
347            self.finished = true;
348        }
349        self.store.finish(&self.run, RunStatus::Finished).await?;
350        Ok(())
351    }
352
353    async fn append(&self, record: Record) -> Result<(), RuntimeError> {
354        self.store
355            .append(&self.run, record.kind(), &record.payload()?)
356            .await?;
357        Ok(())
358    }
359}
360
361/// The node a run begins at.
362///
363/// Several event nodes in one graph is a milestone-2 question — the Run panel
364/// will offer a choice — and guessing at one now would make which event fires
365/// depend on alphabetical order.
366fn single_entry(plan: &CompiledPlan) -> Result<NodeName, RuntimeError> {
367    match plan.entries.as_slice() {
368        [only] => Ok(only.clone()),
369        [] => Err(RuntimeError::NoEntry),
370        many => Err(RuntimeError::ManyEntries(many.len())),
371    }
372}