Skip to main content

aion/engine/
api_workloop.rs

1//! Workloop verbs on the engine API (workloop brief Leg 2): register, close
2//! iteration, retire, hatch.
3
4use std::collections::HashMap;
5use std::future::Future;
6use std::sync::Arc;
7
8use aion_core::{
9    Event, Payload, RunId, SearchAttributeValue, WORKFLOW_KIND_ATTRIBUTE, WORKLOOP_KIND,
10    WorkflowId, WorkflowStatus, WorkloopSpec, status_from_events,
11};
12use aion_store::StoreError;
13use aion_store::workloop::WorkloopStore;
14use chrono::Utc;
15use tokio::task::JoinHandle;
16
17use super::api::Engine;
18use crate::durability::Recorder;
19use crate::error::EngineError;
20use crate::workloop::hatch::{HatchOutcome, derive_identity};
21use crate::workloop::iteration::WorkloopIterationClose;
22use crate::workloop::service::WorkloopService;
23
24/// The engine's running workloop machinery: the service, its store, and the
25/// sweep task with its shutdown line. Present only when the builder was given
26/// a workloop store and a sweep interval.
27pub(crate) struct WorkloopEngineRuntime {
28    pub(crate) service: Arc<WorkloopService>,
29    pub(crate) store: Arc<dyn WorkloopStore>,
30    pub(crate) shutdown: tokio::sync::watch::Sender<bool>,
31    pub(crate) task: JoinHandle<()>,
32    /// The NIF state holding this engine's `close_iteration/3` bridge, so
33    /// shutdown can EMPTY that slot. See [`WorkloopEngineRuntime::stop`].
34    pub(crate) nif_state: Arc<crate::runtime::EngineNifState>,
35}
36
37impl WorkloopEngineRuntime {
38    /// Signal and abort the sweep task, then EMPTY the NIF bridge slot.
39    ///
40    /// Called from both `Engine::shutdown` and `Drop for Engine`, because a
41    /// task spawned on the HOST runtime is not reached by the engine-task
42    /// epoch gate.
43    ///
44    /// # 🔴 THE BRIDGE MUST BE RELEASED, OR THE STORE IS NEVER RELEASED
45    ///
46    /// The bridge holds an `IterationCloseContext` — the workloop store, the
47    /// event store, the visibility store and the registry — and it lives in
48    /// the NIF state, which outlives this engine. So an engine that installed
49    /// a bridge and shut down left its EVENT STORE alive forever, and the next
50    /// process to want that store waited on its file lock: a server that had
51    /// ever built a workloop-capable engine could not hand its data directory
52    /// to a successor. Measured as an indefinite hang in
53    /// `recovery_declared_body_e2e` the moment the server wired the service
54    /// unconditionally — a restart is exactly what that test performs.
55    ///
56    /// Emptying the slot is also the CORRECT runtime behaviour and not merely
57    /// a leak fix: after shutdown there is no engine to close an iteration
58    /// against, and an empty slot is precisely how the NIF says so
59    /// (`no workloop service is configured on this engine`) instead of driving
60    /// a close through a half-torn-down engine.
61    pub(crate) fn stop(&self) {
62        // A closed receiver means the task already exited; nothing to signal.
63        let _ = self.shutdown.send(true);
64        self.task.abort();
65        crate::runtime::nif_workloop::release_workloop_nif_bridge(&self.nif_state);
66    }
67}
68
69impl Engine {
70    pub(crate) fn workloop_runtime(&self) -> Result<&WorkloopEngineRuntime, EngineError> {
71        self.workloop.as_ref().ok_or_else(|| EngineError::Runtime {
72            reason: "workloop service is not configured on this engine \
73                     (EngineBuilder::with_workloop_service)"
74                .to_owned(),
75        })
76    }
77
78    /// The cadence service, when configured — the operational surface for
79    /// sweeps and registration state.
80    #[must_use]
81    pub fn workloop_service(&self) -> Option<Arc<WorkloopService>> {
82        self.workloop
83            .as_ref()
84            .map(|runtime| Arc::clone(&runtime.service))
85    }
86
87    /// The workloop spec the currently ROUTED deployment of `workflow_type`
88    /// declares, or `None` when that deployment is an ordinary workflow.
89    ///
90    /// # 🔴 THE START PATH ASKS THIS, AND IT IS WHY A `.awl` WORKLOOP RUNS
91    ///
92    /// Every caller-facing start surface — HTTP, gRPC, the CLI — funnels into
93    /// one start verb, and that verb cannot tell a workloop from a workflow by
94    /// looking at the request: nothing in a start request says "this is a
95    /// loop". The DEPLOYED PACKAGE says so, in the contract the compiler bound
96    /// into its identity, and this is where that is read. Without it a
97    /// workloop deploys, starts, runs its first iteration and is REFUSED at
98    /// `close_iteration` for not being a registered workloop — which is
99    /// exactly the shape this whole integration exists to close.
100    ///
101    /// Answering from the ROUTED version (not an exact pin) is deliberate: a
102    /// start goes to whatever version routing would run, so the declaration a
103    /// start is registered under must come from the same place.
104    ///
105    /// # Errors
106    ///
107    /// Propagates catalog failures, and refuses a declaration the engine
108    /// cannot act on — an unarmed loop, an invariant with no tolerance or no
109    /// confirming route, a retention window of zero. Those are refused HERE,
110    /// before anything is registered or started, so the loop that cannot be
111    /// armed never exists rather than existing and never firing.
112    pub fn declared_workloop_spec(
113        &self,
114        workflow_type: &str,
115    ) -> Result<Option<WorkloopSpec>, EngineError> {
116        let Some(loaded) = self.catalog.routed(workflow_type)? else {
117            return Ok(None);
118        };
119        // A package with no contract at all is a pre-`.v4` identity that
120        // commits to none; it cannot be a workloop, because the header a loop
121        // needs did not exist under those identities.
122        let Ok(contract) = loaded.contract() else {
123            return Ok(None);
124        };
125        let Some(workloop) = contract.workloop.as_ref() else {
126            return Ok(None);
127        };
128        crate::workloop::spec_from_contract(workflow_type, workloop)
129            .map(Some)
130            .map_err(EngineError::from)
131    }
132
133    /// Registers a STARTED workflow as a workloop: stamps the `aion.kind`
134    /// listing attribute durably in its history and arms the declared cadence
135    /// and tolerance deadlines on the sweep set. Every declared value —
136    /// arming, tolerance, retention — arrives validated inside `spec`; there
137    /// are no defaults to assume.
138    ///
139    /// # Errors
140    ///
141    /// Refuses an unknown or non-Running workflow, a duplicate registration,
142    /// and propagates store/append failures.
143    pub async fn register_workloop(
144        &self,
145        loop_id: &WorkflowId,
146        namespace: String,
147        spec: WorkloopSpec,
148    ) -> Result<(), EngineError> {
149        let runtime = self.workloop_runtime()?;
150        let history = self.store.read_history(loop_id).await?;
151        if history.is_empty() {
152            return Err(EngineError::InvalidState {
153                reason: format!(
154                    "workloop registration requires a started workflow; {loop_id} has no history"
155                ),
156            });
157        }
158        let status = status_from_events(&history);
159        if status != WorkflowStatus::Running {
160            return Err(EngineError::InvalidState {
161                reason: format!(
162                    "workloop registration requires a Running workflow; {loop_id} is {status:?}"
163                ),
164            });
165        }
166        // The duplicate check runs BEFORE the kind stamp so a refused
167        // registration leaves history byte-identical (the service re-checks
168        // under its own put, so a racing duplicate still cannot register
169        // twice — this ordering only keeps the refusal append-free).
170        if runtime
171            .store
172            .get_workloop(loop_id)
173            .await
174            .map_err(EngineError::from)?
175            .is_some()
176        {
177            return Err(EngineError::InvalidState {
178                reason: format!("workloop {loop_id} is already registered"),
179            });
180        }
181
182        // Stamp the kind attribute through the loop's one Recorder so every
183        // listing surface projects it (additive; no status change).
184        let attributes = HashMap::from([(
185            String::from(WORKFLOW_KIND_ATTRIBUTE),
186            SearchAttributeValue::String(String::from(WORKLOOP_KIND)),
187        )]);
188        self.with_loop_recorder(loop_id, |recorder, _history| {
189            let schema = Arc::clone(&self.search_attribute_schema);
190            Box::pin(async move {
191                recorder
192                    .record_search_attributes_updated(Utc::now(), attributes, &schema)
193                    .await
194            })
195        })
196        .await?;
197
198        runtime
199            .service
200            .register(loop_id.clone(), namespace, spec)
201            .await
202            .map_err(EngineError::from)?;
203        Ok(())
204    }
205
206    /// Starts a workflow AS A WORKLOOP: seeds generation 1's carry, registers
207    /// the loop, and only then lets the body run.
208    ///
209    /// # 🔴 THIS EXISTS BECAUSE START-THEN-REGISTER IS A RACE
210    ///
211    /// `register_workloop` requires an already-RUNNING workflow, so the only
212    /// way to stand a loop up was to start it and then register it — and the
213    /// started body begins executing immediately. Generation 1 could reach
214    /// `close_iteration` BEFORE its registration landed, and the close would
215    /// then refuse with "not a registered workloop" and FAIL the run. A loop's
216    /// very first iteration could lose a race with its own registration.
217    ///
218    /// The registration row is therefore written FIRST, before the workflow
219    /// exists, so the close always finds it. A failed start removes the row
220    /// again rather than leaving a registration for a loop that never ran.
221    ///
222    /// # 🔴 AND BECAUSE GENERATION 1 HAS NO PREVIOUS ITERATION TO CARRY FROM
223    ///
224    /// Every later generation gets its carry from the previous iteration's
225    /// `route start` payload. Generation 1 has none, and the compiled input
226    /// codec requires the carry fields regardless — so a start payload that
227    /// passes schema admission would then be UNDECODABLE by the workflow it
228    /// was admitted for. The declared carry defaults are merged into the
229    /// generation-1 input here, filling only ABSENT fields so a caller-supplied
230    /// value is never clobbered by a default.
231    ///
232    /// # 🔴 THE KIND STAMP IS PART OF THE START, NOT A THIRD STEP
233    ///
234    /// The `aion.kind` attribute travels in the START's own attribute map, so
235    /// `WorkflowStarted` and `SearchAttributesUpdated` land in ONE append
236    /// (`Recorder::record_workflow_started_with_attributes`). It used to be a
237    /// separate durable step after the start, and the gap was not cosmetic:
238    /// boot recovery reads the kind FROM HISTORY to decide whether a Running
239    /// workflow with no resident process is a crashed workflow to resurrect or
240    /// a parked LOOP to leave alone. A crash between the start and the stamp
241    /// left a registered loop that boot recovery did not recognise as one — it
242    /// resurrected the generation resident while the sweep set still carried
243    /// the row and would wake it, which is two paths driving one loop, the
244    /// exact thing the skip exists to prevent.
245    ///
246    /// # 🔴 WHAT IS STILL NOT ATOMIC, SAID PLAINLY
247    ///
248    /// Two durable systems are involved — the workloop registration (a KV row)
249    /// and the workflow's event history — and nothing spans them. The
250    /// registration is written FIRST because the alternative loses the race
251    /// above, so a crash between it and the start leaves a sweep-set row for a
252    /// workflow with no history. That row is not left to alarm forever: the
253    /// sweep refuses to fire at a workflow with no recorded start and reports
254    /// the fault, and engine boot reconciliation
255    /// ([`WorkloopService::withdraw_unstarted_registrations`]) withdraws every
256    /// such row. Boot is the point at which the answer is unambiguous — no
257    /// start can be in flight across a process boundary — which is why the
258    /// reconciliation lives there rather than in the sweep, where it would
259    /// race the birth window it is supposed to tolerate.
260    ///
261    /// [`WorkloopService::withdraw_unstarted_registrations`]:
262    ///     crate::workloop::service::WorkloopService::withdraw_unstarted_registrations
263    ///
264    /// # Errors
265    ///
266    /// Refuses an unconfigured workloop service, a start payload that is not a
267    /// JSON object when carry is declared, a duplicate registration, a caller
268    /// attempting to set `aion.kind` itself, and propagates start failures.
269    pub async fn start_workloop(
270        &self,
271        workflow_type: &str,
272        input: Payload,
273        search_attributes: HashMap<String, SearchAttributeValue>,
274        namespace: String,
275        spec: WorkloopSpec,
276    ) -> Result<crate::registry::WorkflowHandle, EngineError> {
277        let runtime = self.workloop_runtime()?;
278        let seeded = spec
279            .carry()
280            .seed(&input)
281            .map_err(|error| EngineError::InvalidState {
282                reason: format!("seeding generation 1's carry refused: {error}"),
283            })?;
284        let attributes = with_workloop_kind(search_attributes)?;
285
286        // The identity is minted here so the registration can precede the
287        // start. Nothing observes it until the start records `WorkflowStarted`
288        // under exactly this id.
289        let loop_id = WorkflowId::new_v4();
290        runtime
291            .service
292            .register(loop_id.clone(), namespace.clone(), spec)
293            .await
294            .map_err(EngineError::from)?;
295
296        let started = self
297            .start_workflow_with_id(
298                workflow_type,
299                seeded,
300                attributes,
301                namespace,
302                Some(loop_id.clone()),
303                None,
304            )
305            .await;
306        match started {
307            Ok(handle) => Ok(handle),
308            Err(error) => {
309                // No loop ran, so no registration should survive. A failure to
310                // withdraw it is reported rather than swallowed: the sweep set
311                // would otherwise carry a row for a workflow that does not
312                // exist until the next boot reconciles it away.
313                if let Err(cleanup) = runtime.service.deregister(&loop_id).await {
314                    tracing::error!(
315                        %loop_id,
316                        start_error = %error,
317                        cleanup_error = %cleanup,
318                        "workloop start failed AND its registration could not be withdrawn; the \
319                         sweep set carries a row for a workflow that was never started until \
320                         boot reconciliation withdraws it"
321                    );
322                }
323                Err(error)
324            }
325        }
326    }
327
328    /// Closes the current iteration at the continue-as-new boundary (R3.1):
329    /// derives one health sample per invariant from the taken routes (R3.3),
330    /// records `IterationClosed` + `WorkflowContinuedAsNew` + the successor
331    /// generation's `WorkflowStarted` in ONE atomic batch through the loop's
332    /// Recorder — spawning NO successor process (R13.3) — then installs the
333    /// produced invariant current-state records with retention pruning (R7/
334    /// R8) and feeds the samples into tolerance accounting. Returns the
335    /// successor generation's run id.
336    ///
337    /// # Errors
338    ///
339    /// Refuses an unregistered loop, a terminal run, pending work, and
340    /// undeclared invariants; propagates store/append failures.
341    pub async fn close_workloop_iteration(
342        &self,
343        loop_id: &WorkflowId,
344        close: WorkloopIterationClose,
345    ) -> Result<RunId, EngineError> {
346        let context = self.iteration_close_context()?;
347        crate::workloop::close::close_iteration(&context, loop_id, close).await
348    }
349
350    /// The component set one iteration close needs, assembled from this
351    /// engine. Shared with the `close_iteration/3` NIF bridge so a close
352    /// reached from compiled workflow code and a close reached from this API
353    /// verb are the SAME close.
354    ///
355    /// # Errors
356    ///
357    /// Refuses when no workloop service is configured on this engine.
358    pub(crate) fn iteration_close_context(
359        &self,
360    ) -> Result<crate::workloop::close::IterationCloseContext, EngineError> {
361        let runtime = self.workloop_runtime()?;
362        Ok(crate::workloop::close::IterationCloseContext {
363            workloop_store: Arc::clone(&runtime.store),
364            service: Arc::clone(&runtime.service),
365            store: self.store(),
366            visibility_store: self.visibility_store(),
367            registry: Arc::clone(&self.registry),
368        })
369    }
370
371    /// Retires a workloop (R2.5): records `LoopRetired { reason }` and its
372    /// `WorkflowCompleted` terminal in ONE atomic batch — the declared,
373    /// recorded way to stop that is not failure — and removes the loop from
374    /// the sweep set. Invariant current-state records survive indefinitely
375    /// (R8.1).
376    ///
377    /// # Errors
378    ///
379    /// Refuses an unconfigured service and a terminal run; propagates
380    /// store/append failures.
381    pub async fn retire_workloop(
382        &self,
383        loop_id: &WorkflowId,
384        reason: String,
385        result: Payload,
386    ) -> Result<(), EngineError> {
387        self.retire_workloop_inner(loop_id, reason, result, RetireBody::Invoke)
388            .await
389    }
390
391    /// [`Engine::retire_workloop`] for a loop that declares NO retire body.
392    ///
393    /// Kept as a separate verb rather than a flag on the main one because the
394    /// difference is a DECLARATION, not a caller preference: a loop whose
395    /// document declares `retire` must run it, and a caller must never be able
396    /// to skip a declared cleanup by passing an argument. The AWL-driven path
397    /// selects between them from the compiled contract; this exists so an
398    /// operator retiring a bodyless loop is not forced through an entry probe
399    /// that would refuse a module which correctly exports nothing.
400    ///
401    /// # Errors
402    ///
403    /// As [`Engine::retire_workloop`], minus the retire-body refusals.
404    pub async fn retire_workloop_without_body(
405        &self,
406        loop_id: &WorkflowId,
407        reason: String,
408        result: Payload,
409    ) -> Result<(), EngineError> {
410        self.retire_workloop_inner(loop_id, reason, result, RetireBody::None)
411            .await
412    }
413
414    /// Retires a workloop, taking the retire-body decision FROM ITS DEPLOYED
415    /// DECLARATION rather than from the caller.
416    ///
417    /// # 🔴 THE OPERATOR SURFACE, AND WHY IT HAS NO "SKIP CLEANUP" FLAG
418    ///
419    /// [`Engine::retire_workloop`] and [`Engine::retire_workloop_without_body`]
420    /// are two verbs precisely so a caller cannot choose: a document that
421    /// declares `retire` must run it, and letting an argument skip a declared
422    /// cleanup is how a lease is released twice or a queue is stranded. This
423    /// is the verb every operator-facing surface calls, and it reads the
424    /// answer out of the package the loop's CURRENT generation is pinned to —
425    /// the same package whose module the body would be spawned from.
426    ///
427    /// A loop whose deployment declares no workloop surface at all (registered
428    /// through the Rust API rather than deployed from a `.awl` document) is
429    /// retired without a body: the engine has no declaration saying there is
430    /// one, and inventing a `retire/1` probe for it would refuse every such
431    /// loop for lacking an entry it was never meant to export.
432    ///
433    /// # Errors
434    ///
435    /// As [`Engine::retire_workloop`], plus catalog and history failures while
436    /// resolving the loop's deployed declaration.
437    pub async fn retire_declared_workloop(
438        &self,
439        loop_id: &WorkflowId,
440        reason: String,
441        result: Payload,
442    ) -> Result<(), EngineError> {
443        let history = self.store.read_history(loop_id).await?;
444        let workflow_type = history
445            .iter()
446            .rev()
447            .find_map(|event| match event {
448                Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
449                _ => None,
450            })
451            .ok_or_else(|| EngineError::InvalidState {
452                reason: format!("workloop {loop_id} has no recorded generation to retire"),
453            })?;
454        let declares_body = self
455            .catalog
456            .routed(&workflow_type)?
457            .and_then(|loaded| loaded.contract().ok().cloned())
458            .and_then(|contract| contract.workloop)
459            .is_some_and(|workloop| workloop.has_retire_body);
460        if declares_body {
461            self.retire_workloop(loop_id, reason, result).await
462        } else {
463            self.retire_workloop_without_body(loop_id, reason, result)
464                .await
465        }
466    }
467
468    async fn retire_workloop_inner(
469        &self,
470        loop_id: &WorkflowId,
471        reason: String,
472        result: Payload,
473        body: RetireBody,
474    ) -> Result<(), EngineError> {
475        let runtime = self.workloop_runtime()?;
476
477        // 🔴 TERMINALITY IS CHECKED BEFORE THE BODY RUNS, NOT AFTER IT.
478        //
479        // The check used to live only inside the terminal append below, so
480        // retiring an ALREADY-RETIRED loop ran the declared cleanup a second
481        // time — releasing a released lease, re-draining a drained queue,
482        // appending after `WorkflowCompleted` — and only then reported the
483        // refusal it had already earned. A refusal that arrives after the
484        // effects is not a refusal.
485        //
486        // The check inside `with_loop_recorder` stays: it is the one taken
487        // under the same acquisition as the append, and it is what makes the
488        // decision atomic rather than merely early. This one is what makes it
489        // EFFECT-FREE.
490        let history = self.store.read_history(loop_id).await?;
491        crate::workloop::retire::refuse_if_terminal(loop_id, &history)
492            .map_err(EngineError::from)?;
493
494        // 🔴 THE RETIRE BODY RUNS FIRST, AND A MISSING ENTRY REFUSES LOUDLY.
495        //
496        // Before any terminal is recorded: a run that already holds its
497        // terminal cannot append, so a body invoked afterwards could record
498        // nothing it did. And a declared body whose entry is absent from the
499        // deployed module is REFUSED — the engine cannot tell "compiled
500        // before the retire entry existed" from "declared no cleanup", and
501        // silently skipping the first is how a lease is lost.
502        if body == RetireBody::Invoke {
503            crate::workloop::retire::run_retire_body(&crate::workloop::retire::RetireInvocation {
504                loop_id,
505                runtime: &self.runtime,
506                catalog: self.catalog.as_ref(),
507                registry: &self.registry,
508                store: &self.store,
509                visibility_store: &self.visibility_store(),
510            })
511            .await
512            .map_err(EngineError::from)?;
513        }
514        let retire_reason = reason;
515        self.with_loop_recorder(loop_id, move |recorder, history| {
516            let reason = retire_reason.clone();
517            let result = result.clone();
518            let terminal = aion_core::current_lease_terminal(history).is_some();
519            Box::pin(async move {
520                if terminal {
521                    return Err(crate::durability::DurabilityError::HistoryShape {
522                        reason: "cannot retire a workloop whose run is already terminal".to_owned(),
523                    });
524                }
525                recorder
526                    .record_loop_retired(Utc::now(), reason, result)
527                    .await
528            })
529        })
530        .await?;
531
532        // 🔴 DEREGISTER BEFORE THE HANDLE GOES, AND ACCOUNT FOR THE ANSWER.
533        //
534        // Between the terminal above and the deregistration below the loop is
535        // a REGISTERED row whose run holds a terminal — precisely the shape
536        // the sweep reads as loop death (`sink.rs` refuses the cadence fire on
537        // a terminal run, and `declare_loop_dead` then fans an `AlarmCause::
538        // LoopDead` at every declared invariant). A cleanly retired loop would
539        // be declared dead, durably, in its own history. The sweep now checks
540        // for `LoopRetired` before declaring death, and this ordering narrows
541        // the window it has to check in.
542        //
543        // The `bool` is not discarded: `deregister` answering `false` means
544        // the row was already gone, which on this path means a sweep removed
545        // it — the operator's retirement succeeded but the loop had already
546        // been swept, and that is worth a line rather than a shrug.
547        let was_registered = runtime
548            .service
549            .deregister(loop_id)
550            .await
551            .map_err(EngineError::from)?;
552        if !was_registered {
553            tracing::warn!(
554                %loop_id,
555                "workloop retirement recorded its terminal but the loop was no longer on the \
556                 sweep set; a sweep deregistered it first, so its invariants may already carry \
557                 loop-dead alarms for a loop that was being retired on purpose"
558            );
559        }
560        if let Some(handle) = self.registry_handle(loop_id)? {
561            let run = handle.run_id().clone();
562            self.registry.remove(loop_id, &run)?;
563        }
564        Ok(())
565    }
566
567    /// Starts a DETACHED top-level workflow under the mandatory dedupe
568    /// identity (R13.1): `WorkflowId = hatch_workflow_id(namespace, type,
569    /// key)`. Not a child — no lifecycle tie, no supervision edge. A
570    /// duplicate hatch is a recorded no-op returning the existing workflow id;
571    /// two racing first-hatches are settled by the store's optimistic append,
572    /// the loser resolving to the winner's workflow.
573    ///
574    /// # Errors
575    ///
576    /// Refuses empty/NUL identity parts and propagates start failures other
577    /// than the dedupe race.
578    pub async fn hatch_workflow(
579        &self,
580        namespace: &str,
581        workflow_type: &str,
582        key: &str,
583        input: Payload,
584        search_attributes: HashMap<String, SearchAttributeValue>,
585    ) -> Result<HatchOutcome, EngineError> {
586        let hatch_id = derive_identity(namespace, workflow_type, key).map_err(|error| {
587            EngineError::InvalidState {
588                reason: format!("hatch identity refused: {error}"),
589            }
590        })?;
591        if !self.store.read_history(&hatch_id).await?.is_empty() {
592            return Ok(HatchOutcome::Existing(hatch_id));
593        }
594        match self
595            .start_workflow_with_id(
596                workflow_type,
597                input,
598                search_attributes,
599                namespace.to_owned(),
600                Some(hatch_id.clone()),
601                None,
602            )
603            .await
604        {
605            Ok(_handle) => Ok(HatchOutcome::Hatched(hatch_id)),
606            // The dedupe race: a concurrent hatch of the same identity won the
607            // first append. The store's optimistic concurrency IS the index —
608            // the loser resolves to the existing workflow, never a second one.
609            Err(EngineError::Store(StoreError::SequenceConflict { .. })) => {
610                Ok(HatchOutcome::Existing(hatch_id))
611            }
612            Err(error) => Err(error),
613        }
614    }
615
616    fn registry_handle(
617        &self,
618        loop_id: &WorkflowId,
619    ) -> Result<Option<crate::registry::WorkflowHandle>, EngineError> {
620        // aion#213: `with_loop_recorder` APPENDS DURABLY through whatever this
621        // returns — see the twin in `workloop/close.rs`.
622        self.registry.sole_handle(loop_id)
623    }
624
625    /// Append through the loop's ONE Recorder: the live handle's recorder
626    /// when registered, a one-shot `Recorder::resume_at` when suspended (the
627    /// sanctioned non-resident pattern). The closure receives the history
628    /// read under the same acquisition, so check-then-append is not
629    /// interleaved.
630    async fn with_loop_recorder<T>(
631        &self,
632        loop_id: &WorkflowId,
633        record: impl for<'a> FnOnce(
634            &'a mut Recorder,
635            &'a [Event],
636        ) -> std::pin::Pin<
637            Box<dyn Future<Output = Result<T, crate::durability::DurabilityError>> + Send + 'a>,
638        >,
639    ) -> Result<T, EngineError> {
640        if let Some(handle) = self.registry_handle(loop_id)? {
641            let recorder = handle.recorder();
642            let mut recorder = recorder.lock().await;
643            let history = self.store.read_history(loop_id).await?;
644            let value = record(&mut recorder, &history).await?;
645            return Ok(value);
646        }
647        let history = self.store.read_history(loop_id).await?;
648        let head = history.iter().map(Event::seq).max().unwrap_or_default();
649        let mut recorder = Recorder::resume_at(loop_id.clone(), self.store(), head);
650        if let Some(run_id) = active_run_id(&history) {
651            recorder = recorder.with_visibility(run_id, self.visibility_store());
652        }
653        let value = record(&mut recorder, &history).await?;
654        Ok(value)
655    }
656}
657
658/// Whether a retirement invokes the loop's declared retire body.
659///
660/// Not a boolean: `retire(loop, reason, result, true)` at a call site says
661/// nothing about what `true` means, and this decision is load-bearing enough
662/// that a reader must not have to look it up.
663#[derive(Clone, Copy, Debug, PartialEq, Eq)]
664enum RetireBody {
665    /// The document declares a `retire` block; run it, and refuse if the
666    /// deployed module exports no entry for it.
667    Invoke,
668    /// The document declares no `retire` block.
669    None,
670}
671
672/// The caller's attributes plus the workloop kind stamp, refusing a caller
673/// that tried to set the stamp itself.
674///
675/// The refusal is not fussiness. `aion.kind` is what boot recovery reads to
676/// decide whether a Running workflow with no process is a crash to resurrect
677/// or a park to leave alone, so a caller that could write it could make an
678/// ordinary workflow claim to be a loop — or, worse, silently disagree with
679/// the registration this very call is about to write.
680fn with_workloop_kind(
681    mut attributes: HashMap<String, SearchAttributeValue>,
682) -> Result<HashMap<String, SearchAttributeValue>, EngineError> {
683    if let Some(existing) = attributes.get(WORKFLOW_KIND_ATTRIBUTE) {
684        return Err(EngineError::InvalidState {
685            reason: format!(
686                "`{WORKFLOW_KIND_ATTRIBUTE}` is stamped by the engine and cannot be supplied by \
687                 a caller; it was given as {existing:?}. It is the attribute boot recovery reads \
688                 to tell a parked workloop from a crashed workflow, and a caller-set value could \
689                 disagree with the registration this start writes"
690            ),
691        });
692    }
693    attributes.insert(
694        String::from(WORKFLOW_KIND_ATTRIBUTE),
695        SearchAttributeValue::String(String::from(WORKLOOP_KIND)),
696    );
697    Ok(attributes)
698}
699
700fn active_run_id(history: &[Event]) -> Option<RunId> {
701    history.iter().rev().find_map(|event| match event {
702        Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
703        _ => None,
704    })
705}