Skip to main content

aion/engine/
api.rs

1//! `Engine` start, cancel, result, list, and shutdown support.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use aion_core::{
7    Event, Payload, RunId, SearchAttributeSchema, SearchAttributeValue, WorkflowError,
8    WorkflowFilter, WorkflowId, WorkflowSummary,
9};
10use tokio::sync::Mutex as AsyncMutex;
11use tokio::task::JoinHandle;
12
13use crate::durability::Recorder;
14use crate::schedule::ScheduleEvaluator;
15use aion_store::EventStore;
16use aion_store::visibility::VisibilityStore;
17
18use crate::lifecycle::continue_as_new::{self, ContinueAsNewContext, ContinueAsNewRequest};
19use crate::lifecycle::start::{self, StartWorkflowContext};
20use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
21use crate::lifecycle::transition;
22use crate::registry::{TerminalOutcome, WorkflowHandle};
23use crate::{
24    EngineError, Registry, RuntimeHandle, SupervisionTree, WorkflowCatalog,
25    signal::SignalResumeHandoff,
26};
27
28use super::api_schedule::{
29    ScheduleRuntimeDeps, default_schedule_evaluator, schedule_coordinator_workflow_id,
30};
31use super::delegated::DelegatedSeams;
32use super::shutdown_gate::ShutdownGate;
33use crate::time::timer_service::live_timers_in_active_segment;
34
35/// Live embedded workflow engine assembled by [`crate::EngineBuilder`].
36pub struct Engine {
37    store: Arc<dyn EventStore>,
38    visibility_store: Arc<dyn VisibilityStore>,
39    pub(super) schedule_recorder: Arc<AsyncMutex<Recorder>>,
40    pub(super) schedule_evaluator: Arc<AsyncMutex<ScheduleEvaluator>>,
41    pub(super) schedule_coordinator_workflow_id: WorkflowId,
42    runtime: Arc<RuntimeHandle>,
43    catalog: Arc<WorkflowCatalog>,
44    registry: Arc<Registry>,
45    supervision: Arc<SupervisionTree>,
46    delegated: DelegatedSeams,
47    signal_handoff: Arc<SignalResumeHandoff>,
48    search_attribute_schema: Arc<SearchAttributeSchema>,
49    pub(super) shutdown_gate: ShutdownGate,
50    /// Serializes the deploy mutations (load / route / unload) end-to-end
51    /// across BOTH the catalog commit and its store persistence write, so
52    /// the persisted package set and route pointers can never disagree with
53    /// the catalog through interleaving (for example a concurrent re-deploy
54    /// re-persisting a version an unload just deleted). Workflow dispatch
55    /// never takes this lock.
56    pub(super) deploy_mutations: AsyncMutex<()>,
57    visibility_reconciliation_task: Option<JoinHandle<()>>,
58}
59
60/// Components required to construct an [`Engine`].
61pub(crate) struct EngineComponents {
62    pub(crate) store: Arc<dyn EventStore>,
63    pub(crate) visibility_store: Arc<dyn VisibilityStore>,
64    pub(crate) runtime: Arc<RuntimeHandle>,
65    pub(crate) catalog: Arc<WorkflowCatalog>,
66    pub(crate) registry: Arc<Registry>,
67    pub(crate) supervision: Arc<SupervisionTree>,
68    pub(crate) delegated: DelegatedSeams,
69    pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
70    pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
71    pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
72}
73
74impl Engine {
75    /// Construct an engine from already-assembled components.
76    #[must_use]
77    pub(crate) fn new(components: EngineComponents) -> Self {
78        let EngineComponents {
79            store,
80            visibility_store,
81            runtime,
82            catalog,
83            registry,
84            supervision,
85            delegated,
86            signal_handoff,
87            search_attribute_schema,
88            visibility_reconciliation_task,
89        } = components;
90        let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
91        let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
92            schedule_coordinator_workflow_id.clone(),
93            Arc::clone(&store),
94        )));
95        let runtime_arc = runtime;
96        let registry_arc = registry;
97        let supervision_arc = supervision;
98        let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
99            schedule_coordinator_workflow_id.clone(),
100            Arc::clone(&schedule_recorder),
101            ScheduleRuntimeDeps {
102                store: Arc::clone(&store),
103                visibility_store: Arc::clone(&visibility_store),
104                runtime: Arc::clone(&runtime_arc),
105                catalog: Arc::clone(&catalog),
106                registry: Arc::clone(&registry_arc),
107                supervision: Arc::clone(&supervision_arc),
108                search_attribute_schema: Arc::clone(&search_attribute_schema),
109            },
110        )));
111        Self {
112            store,
113            visibility_store,
114            schedule_recorder,
115            schedule_evaluator,
116            schedule_coordinator_workflow_id,
117            runtime: runtime_arc,
118            catalog,
119            registry: registry_arc,
120            supervision: supervision_arc,
121            delegated,
122            signal_handoff,
123            search_attribute_schema,
124            shutdown_gate: ShutdownGate::default(),
125            deploy_mutations: AsyncMutex::new(()),
126            visibility_reconciliation_task,
127        }
128    }
129
130    /// Advance the schedule coordinator's recorder head to match persisted
131    /// events so that a rebuilt engine resumes appending at the correct
132    /// sequence rather than conflicting at head 0.
133    ///
134    /// # Errors
135    ///
136    /// Returns store read errors.
137    pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
138        let history = self
139            .store
140            .read_history(&self.schedule_coordinator_workflow_id)
141            .await?;
142        let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
143        if head > 0 {
144            let mut recorder = self.schedule_recorder.lock().await;
145            *recorder = Recorder::resume_at(
146                self.schedule_coordinator_workflow_id.clone(),
147                Arc::clone(&self.store),
148                head,
149            );
150        }
151        Ok(())
152    }
153
154    /// Event store used by lifecycle and delegated AD/AT operations.
155    #[must_use]
156    pub fn store(&self) -> Arc<dyn EventStore> {
157        Arc::clone(&self.store)
158    }
159
160    /// Visibility store used for workflow summary projections.
161    #[must_use]
162    pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
163        Arc::clone(&self.visibility_store)
164    }
165
166    /// Runtime boundary assembled for this engine.
167    #[must_use]
168    pub fn runtime(&self) -> &RuntimeHandle {
169        &self.runtime
170    }
171
172    /// Shared workflow package catalog: loaded versions and routing.
173    #[must_use]
174    pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
175        &self.catalog
176    }
177
178    /// Active execution registry.
179    #[must_use]
180    pub fn registry(&self) -> &Registry {
181        &self.registry
182    }
183
184    /// Supervision tree snapshot/model.
185    #[must_use]
186    pub fn supervision(&self) -> &SupervisionTree {
187        &self.supervision
188    }
189
190    /// Delegated signal/query/subscribe seams installed for AT/AD integration.
191    #[must_use]
192    pub const fn delegated(&self) -> &DelegatedSeams {
193        &self.delegated
194    }
195
196    /// Shared in-memory handoff for already-recorded non-resident signals.
197    #[must_use]
198    pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
199        Arc::clone(&self.signal_handoff)
200    }
201
202    /// Start a loaded workflow type as a new BEAM process.
203    ///
204    /// `search_attributes` are validated against the engine's configured
205    /// [`SearchAttributeSchema`] and recorded atomically with the
206    /// `WorkflowStarted` event, so visibility metadata can never be lost to a
207    /// crash between start and a later attribute update.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
212    /// [`EngineError::Durability`] when a search attribute is unregistered or
213    /// mistyped (nothing is appended and no process is spawned). Otherwise
214    /// delegates to the start lifecycle transition and returns its typed errors.
215    pub async fn start_workflow(
216        &self,
217        workflow_type: &str,
218        input: Payload,
219        search_attributes: HashMap<String, SearchAttributeValue>,
220        namespace: String,
221    ) -> Result<WorkflowHandle, EngineError> {
222        self.start_workflow_with_id(
223            workflow_type,
224            input,
225            search_attributes,
226            namespace,
227            None,
228            None,
229        )
230        .await
231    }
232
233    /// Start a loaded workflow type, optionally with a caller-chosen
234    /// `workflow_id` and/or R-4 steered-start `routing_key`.
235    ///
236    /// The request-routing edge supplies `workflow_id` to *place* a new start on
237    /// a shard this node owns: the R-1 unsteered-start remint (any locally-owned
238    /// shard) or, for a steered start, an id the edge derived on the
239    /// `routing_key`'s shard before deciding to run locally. So a `start` whose
240    /// id would otherwise hash to a non-owned shard never fences. When
241    /// `workflow_id` is `None` this is identical to [`Self::start_workflow`]: the
242    /// lifecycle mints a fresh `WorkflowId`, so the default single-node path is
243    /// unchanged.
244    ///
245    /// `routing_key` is the caller-chosen steered-start key recorded on the start
246    /// options. Shard derivation for the cluster path is performed at the edge
247    /// (which holds the concrete cluster store); here it is threaded through for
248    /// API completeness and direct callers.
249    ///
250    /// # Errors
251    ///
252    /// Identical to [`Self::start_workflow`]. A supplied `workflow_id` is treated
253    /// as a fresh execution; the caller is responsible for choosing an unused id.
254    pub async fn start_workflow_with_id(
255        &self,
256        workflow_type: &str,
257        input: Payload,
258        search_attributes: HashMap<String, SearchAttributeValue>,
259        namespace: String,
260        workflow_id: Option<WorkflowId>,
261        routing_key: Option<String>,
262    ) -> Result<WorkflowHandle, EngineError> {
263        let operation = self.shutdown_gate.begin_start()?;
264        let result = start::start_workflow_with_options(
265            StartWorkflowContext {
266                store: self.store(),
267                visibility_store: self.visibility_store(),
268                catalog: Arc::clone(&self.catalog),
269                runtime: Arc::clone(&self.runtime),
270                supervision: Arc::clone(&self.supervision),
271                registry: Arc::clone(&self.registry),
272                signal_handoff: Some(self.signal_handoff()),
273                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
274                monitor_tokio_handle: tokio::runtime::Handle::current(),
275            },
276            workflow_type,
277            input,
278            start::StartWorkflowOptions {
279                namespace: Some(namespace),
280                search_attributes,
281                workflow_id,
282                routing_key,
283                ..start::StartWorkflowOptions::default()
284            },
285        )
286        .await;
287        drop(operation);
288        result
289    }
290
291    /// Absorb a dead peer's distribution shards into this LIVE engine and resume
292    /// their orphaned workflows — the SS-5 failover entry point.
293    ///
294    /// This is the production failover step a cluster supervisor invokes when it
295    /// observes a peer gone (membership loss). It is the post-boot counterpart to
296    /// the boot path's `EngineBuilder::owned_shards` election + recovery, run
297    /// against an already-running engine:
298    ///
299    /// 1. **Elect + union-merge.** `acquire_owned_shards` wins the per-shard
300    ///    election for each `shards` entry (fencing the dead owner) and
301    ///    `become_live` union-merges that shard's committed history locally, so
302    ///    every event the dead node had quorum-committed is now present on this
303    ///    node. The election is blocking and runs off the tokio runtime inside the
304    ///    store seam, honouring haematite's no-blocking-election-in-async
305    ///    constraint, so this `async` method may call it directly.
306    /// 2. **Widen the scope.** `extend_owned_shards` unions `shards` into this
307    ///    node's owned-enumeration set so the adopted workflows, timers, and
308    ///    outbox rows become visible to enumeration WITHOUT dropping this node's
309    ///    own shards.
310    /// 3. **Publish ownership.** `publish_shard_owner` records this node as each
311    ///    adopted shard's current owner in the cluster's quorum-replicated
312    ///    shard-owner directory (SS-3), so a request reaching a DIFFERENT survivor
313    ///    routes to this adopter rather than mis-resolving to the dead declared
314    ///    owner. The publish is fenced by the election just won, so only the true
315    ///    adopter writes it; a non-distributed store no-ops it.
316    /// 4. **Re-resident.** Re-run the idempotent active-workflow recovery and
317    ///    timer recovery, which re-spawn every adopted workflow from the
318    ///    union-merged history through the same production recovery seam the boot
319    ///    path uses, skipping the workflows this node already owns.
320    ///
321    /// Detection of the peer's death is the CALLER's responsibility (a cluster
322    /// supervisor / membership-loss trigger); this method performs the
323    /// re-acquisition and resume once that decision is made. It is idempotent:
324    /// adopting a shard this node already serves re-acquires (a no-op on the
325    /// fence it already holds) and recovers nothing new.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, store errors
330    /// from the election / union-merge ([`EngineError::Durability`]), and any
331    /// typed recovery error from re-residenting an adopted workflow.
332    pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
333        let operation = self.shutdown_gate.begin_start()?;
334        let result = self.adopt_shards_inner(shards).await;
335        drop(operation);
336        result
337    }
338
339    /// Body of [`Self::adopt_shards`]: acquire+publish each shard as a UNIT under
340    /// the double-adoption fence (ADR-021 clean-partial), then widen scope and
341    /// recover over EXACTLY the shards that survived BOTH steps.
342    ///
343    /// ## Ordering invariant (the fix)
344    ///
345    /// For each shard the publish-fence happens BEFORE the shard contributes to
346    /// `extend_owned_shards` AND before it is recovered. The pre-fix order
347    /// (extend → publish) let a survivor that won the election but was then
348    /// deposed at publish-time still widen its scope and recover the shard, so two
349    /// survivors could both execute its workflows. Here, a `NotOwner` from EITHER
350    /// `acquire_owned_shard` OR `publish_shard_owner` DROPS that shard: it never
351    /// reaches `extend_owned_shards`, is never recovered, and is NEVER a hard
352    /// `Durability` error. A deposed survivor therefore leaves ZERO widened
353    /// owned-shards scope and recovers nothing.
354    async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
355        // 1-3. Drive the double-adoption fence in the FIXED order (acquire →
356        //      publish per shard as a UNIT, then re-assert ownership and widen the
357        //      enumeration scope ONCE) and learn which shards survived it. A shard
358        //      deposed at acquire OR publish (or in the residual window) is dropped
359        //      cleanly — never extended, never recovered, never a hard error. The
360        //      planner GUARANTEES each survivor's publish-fence precedes both the
361        //      scope widening and (below) recovery. A single-node store no-ops
362        //      every step, so this path stays byte-identical there.
363        // The returned survivor set is already reflected in the store's widened
364        // owned-shard scope (the planner's single `extend`), which is what recovery
365        // enumerates over; the value is bound only to make that contract explicit.
366        let _recoverable =
367            super::fence::plan_adopted_shards(&super::fence::StoreFenceSeam { store: &*self.store }, shards)?;
368        // 4. Re-resident the adopted workflows through the production recovery
369        //    seam (idempotent: this node's own workflows are skipped). Recovery
370        //    enumerates over the owned scope, which now contains only shards that
371        //    survived the fence.
372        super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
373            store: Arc::clone(&self.store),
374            visibility_store: Arc::clone(&self.visibility_store),
375            runtime: Arc::clone(&self.runtime),
376            catalog: Arc::clone(&self.catalog),
377            registry: Arc::clone(&self.registry),
378            supervision: Arc::clone(&self.supervision),
379            recovery: None,
380            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
381            bootstrap_schedule_coordinator: false,
382        })
383        .await?;
384        super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
385            .await
386    }
387
388    /// Resume a suspended workflow run and flush deferred signals through its mailbox.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
393    /// is absent, or registry errors from the residency transition. Deferred
394    /// delivery failures are logged and dropped because signals are already durable.
395    pub fn resume_workflow(
396        &self,
397        id: &WorkflowId,
398        run: &RunId,
399    ) -> Result<WorkflowHandle, EngineError> {
400        let handle = transition::resume(self.registry(), id, run)?;
401        if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
402            tracing::warn!(
403                workflow_id = %id,
404                run_id = %run,
405                error = %error,
406                "failed to flush deferred signals after workflow resume"
407            );
408        }
409        Ok(handle)
410    }
411
412    /// Cancel a live workflow run by killing its runtime process.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
417    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
418    /// is not live. Other typed errors come from the cancel transition.
419    pub async fn cancel(
420        &self,
421        id: &WorkflowId,
422        run: &RunId,
423        reason: impl Into<String>,
424    ) -> Result<(), EngineError> {
425        let operation = self.shutdown_gate.begin_operation()?;
426        // Tear down the run's in-flight durable timers BEFORE the cancel
427        // transition. Cancellation that leaves a live timer behind orphans it:
428        // recovery later tries to fire it against a workflow that no longer
429        // exists. See `cancel_inflight_timers` for the ordering constraints.
430        self.cancel_inflight_timers(id).await;
431        let result = terminate::cancel(
432            TerminateWorkflowContext {
433                runtime: &self.runtime,
434                store: self.store(),
435                visibility_store: self.visibility_store(),
436                registry: &self.registry,
437            },
438            id,
439            run,
440            reason,
441        )
442        .await;
443        drop(operation);
444        result
445    }
446
447    /// Cancel the workflow's in-flight durable timers, routed through the
448    /// production [`crate::time::TimerService`] so each records a
449    /// `TimerCancelled` (and disarms the resident wheel) under the service's
450    /// terminal-update guard. Once `TimerCancelled` is in history the timer is
451    /// dead everywhere — a later wheel or recovery fire no-ops on the liveness
452    /// check — so a cancelled workflow no longer leaves orphaned timers that
453    /// brick startup recovery.
454    ///
455    /// Ordering matters and is the reason this lives in `Engine::cancel` rather
456    /// than inside `terminate::cancel`:
457    /// * It runs **before** `terminate::cancel`, while the workflow is still in
458    ///   the registry (before `terminate::cancel`'s final `registry.remove`), so
459    ///   the timer bridge's registry lookup succeeds. `UnknownWorkflow` is raised
460    ///   only when the workflow is absent from the registry entirely — a
461    ///   suspended (non-resident-but-registered) workflow is fine: its wheel
462    ///   disarm is skipped but `TimerCancelled` is still recorded.
463    /// * It runs **outside** `terminate::cancel`'s recorder lock —
464    ///   `TimerService::cancel` re-acquires that same per-handle lock to record,
465    ///   and the tokio mutex is not reentrant.
466    ///
467    /// Best-effort by design: every failure path here is backstopped by
468    /// `recover_due`'s orphaned-timer skip (see [`crate::time`]'s recovery
469    /// module), so it is logged but never fails the cancel. The only residual
470    /// orphan window — a timer armed in the instant between enumeration and the
471    /// process kill — is absorbed by that same recovery skip.
472    async fn cancel_inflight_timers(&self, id: &WorkflowId) {
473        let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
474            self.runtime.nif_state(),
475        ) {
476            Ok(service) => service,
477            Err(error) => {
478                tracing::warn!(
479                    %error,
480                    workflow_id = %id,
481                    "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
482                );
483                return;
484            }
485        };
486        let history = match self.store.read_history(id).await {
487            Ok(history) => history,
488            Err(error) => {
489                tracing::warn!(
490                    %error,
491                    workflow_id = %id,
492                    "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
493                );
494                return;
495            }
496        };
497        for timer_id in live_timers_in_active_segment(&history) {
498            if let Err(error) = timer_service.cancel(id.clone(), timer_id.clone()).await {
499                tracing::warn!(
500                    %error,
501                    workflow_id = %id,
502                    %timer_id,
503                    "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
504                );
505            }
506        }
507    }
508
509    /// Continue a live workflow run as a new run under the same workflow id.
510    ///
511    /// # Errors
512    ///
513    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
514    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
515    /// is not live. Other typed errors come from the continue-as-new transition.
516    pub async fn continue_as_new(
517        &self,
518        id: &WorkflowId,
519        run: &RunId,
520        input: Payload,
521        workflow_type: Option<String>,
522    ) -> Result<WorkflowHandle, EngineError> {
523        let operation = self.shutdown_gate.begin_operation()?;
524        let result = continue_as_new::continue_as_new(
525            ContinueAsNewContext {
526                store: self.store(),
527                visibility_store: Arc::clone(&self.visibility_store),
528                catalog: Arc::clone(&self.catalog),
529                runtime: &self.runtime,
530                supervision: Arc::clone(&self.supervision),
531                registry: &self.registry,
532                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
533            },
534            id,
535            run,
536            ContinueAsNewRequest {
537                input,
538                workflow_type,
539            },
540        )
541        .await;
542        drop(operation);
543        result
544    }
545
546    /// Await a workflow run's terminal result.
547    ///
548    /// Already-terminal histories return immediately. Live workflows await their
549    /// completion notifier. Unknown workflow/run pairs return not found.
550    ///
551    /// # Errors
552    ///
553    /// Returns store, registry, or runtime channel errors as typed [`EngineError`]
554    /// variants, or [`EngineError::WorkflowNotFound`] when no live handle or
555    /// terminal history exists for the requested pair.
556    pub async fn result(
557        &self,
558        id: &WorkflowId,
559        run: &RunId,
560    ) -> Result<Result<Payload, WorkflowError>, EngineError> {
561        let history = self.store.read_history(id).await?;
562        if let Some(outcome) = terminal_outcome_from_history(&history) {
563            return Ok(outcome_to_result(outcome));
564        }
565
566        let handle = match self.registry.get(id, run)? {
567            Some(handle) => handle,
568            // Registration birth window: the run is durably started but its
569            // handle insert has not landed yet (see
570            // `Engine::handle_after_birth_window`).
571            None => self
572                .handle_after_birth_window(id, run, &history)
573                .await?
574                .ok_or_else(|| workflow_not_found(id, run))?,
575        };
576        let mut receiver = handle.completion().subscribe();
577        loop {
578            if let Some(outcome) = receiver.borrow().clone() {
579                return Ok(outcome_to_result(outcome));
580            }
581            if receiver.changed().await.is_err() {
582                if let Some(outcome) =
583                    terminal_outcome_from_history(&self.store.read_history(id).await?)
584                {
585                    return Ok(outcome_to_result(outcome));
586                }
587                return Err(EngineError::Runtime {
588                    reason: format!(
589                        "completion channel closed before workflow `{id}/{run}` finished"
590                    ),
591                });
592            }
593        }
594    }
595
596    /// List live and terminal workflow summaries matching `filter`.
597    ///
598    /// Store projections are authoritative; live registry entries are projected
599    /// from durable history before being merged and deduplicated.
600    ///
601    /// # Errors
602    ///
603    /// Returns typed store or registry errors when visibility data cannot be read.
604    pub async fn list_workflows(
605        &self,
606        filter: WorkflowFilter,
607    ) -> Result<Vec<WorkflowSummary>, EngineError> {
608        let mut summaries = self
609            .store
610            .query(&filter)
611            .await?
612            .into_iter()
613            .map(|summary| (summary.workflow_id.clone(), summary))
614            .collect::<HashMap<_, _>>();
615
616        for handle in self.registry.list()? {
617            let history = self.store.read_history(handle.workflow_id()).await?;
618            self.registry
619                .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
620            if let Some(summary) = WorkflowSummary::from_history(&history) {
621                if filter.matches(&summary) {
622                    summaries.insert(summary.workflow_id.clone(), summary);
623                }
624            }
625        }
626
627        let mut summaries = summaries.into_values().collect::<Vec<_>>();
628        summaries.sort_by(|left, right| {
629            left.started_at.cmp(&right.started_at).then_with(|| {
630                left.workflow_id
631                    .to_string()
632                    .cmp(&right.workflow_id.to_string())
633            })
634        });
635        Ok(summaries)
636    }
637
638    /// Gracefully stop accepting new starts and shut down the embedded runtime.
639    ///
640    /// # Errors
641    ///
642    /// Returns registry poison or runtime shutdown failures as typed errors.
643    pub fn shutdown(&self) -> Result<(), EngineError> {
644        if let Some(task) = &self.visibility_reconciliation_task {
645            task.abort();
646        }
647        self.shutdown_gate.close_and_wait()?;
648        // Epoch close for engine-side child tasks (F4): the scheduler stops
649        // first (so no NIF can arm a new watcher mid-shutdown), then every
650        // watcher and spawn-recovery task is aborted AND awaited to
651        // quiescence — a task still mid-record after shutdown could
652        // double-write a parent history a successor engine over the same
653        // store also records into. Arming is additionally gated inside the
654        // task registry the moment shutdown begins.
655        self.runtime.shutdown()?;
656        self.runtime.nif_state().shutdown_child_tasks();
657        Ok(())
658    }
659}
660
661pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
662    // Reset-aware via the shared single-source predicate: the current lease's
663    // terminal event, where a reopen (WorkflowReopened) supersedes any earlier
664    // terminal.
665    match aion_core::current_lease_terminal(events)? {
666        Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
667        Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
668        Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
669        Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
670        Event::WorkflowContinuedAsNew {
671            input,
672            workflow_type,
673            parent_run_id,
674            ..
675        } => Some(TerminalOutcome::ContinuedAsNew {
676            input: input.clone(),
677            workflow_type: workflow_type.clone(),
678            parent_run_id: parent_run_id.clone(),
679        }),
680        _ => None,
681    }
682}
683
684fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
685    match outcome {
686        TerminalOutcome::Completed(payload) => Ok(payload),
687        TerminalOutcome::Failed(error) => Err(error),
688        TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
689            message: format!("workflow cancelled: {reason}"),
690            details: None,
691        }),
692        TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
693            message: format!("workflow timed out: {timeout}"),
694            details: None,
695        }),
696        TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
697            message: format!("workflow continued as new from run {parent_run_id}"),
698            details: None,
699        }),
700    }
701}
702
703pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
704    EngineError::WorkflowNotFound {
705        workflow_type: format!("{id}/{run}"),
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use std::collections::HashMap;
712    use std::sync::Arc;
713    use std::time::Duration;
714
715    use aion_core::{
716        Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema, TimerId,
717        WorkflowFilter, WorkflowId, WorkflowStatus,
718    };
719    use aion_package::ContentHash;
720    use aion_store::visibility::VisibilityStore;
721    use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
722    use serde_json::json;
723
724    use super::{DelegatedSeams, Engine, EngineComponents, live_timers_in_active_segment};
725    use crate::durability::Recorder;
726    use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
727    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
728    use crate::time::TimerRecovery;
729    use crate::{
730        EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
731        WorkflowHandle,
732    };
733
734    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
735        Payload::from_json(&json!({ "label": label }))
736    }
737
738    fn workflow_error(message: &str) -> aion_core::WorkflowError {
739        aion_core::WorkflowError {
740            message: message.to_owned(),
741            details: None,
742        }
743    }
744
745    fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
746        let catalog = Arc::new(WorkflowCatalog::new());
747        catalog.note_loaded_workflow_for_test(
748            workflow_type,
749            deployed_module,
750            "run",
751            ContentHash::from_bytes([5; 32]),
752        );
753        catalog
754    }
755
756    fn engine_with_loaded_workflow(
757        store: Arc<dyn EventStore>,
758        workflow_type: &str,
759        deployed_module: &str,
760    ) -> Result<Engine, EngineError> {
761        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
762        runtime.register_waiting_test_module(deployed_module, "run");
763        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
764        Ok(Engine::new(EngineComponents {
765            store,
766            visibility_store,
767            runtime: Arc::new(runtime),
768            catalog: workflow_catalog(workflow_type, deployed_module),
769            registry: Arc::new(Registry::default()),
770            supervision: Arc::new(SupervisionTree::new()),
771            delegated: DelegatedSeams::default(),
772            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
773            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
774            visibility_reconciliation_task: None,
775        }))
776    }
777
778    fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
779        TerminateWorkflowContext {
780            runtime: engine.runtime(),
781            store: engine.store(),
782            visibility_store: engine.visibility_store(),
783            registry: engine.registry(),
784        }
785    }
786
787    async fn insert_active_handle(
788        engine: &Engine,
789        store: Arc<dyn EventStore>,
790        workflow_type: &str,
791    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
792        let workflow_id = aion_core::WorkflowId::new_v4();
793        let run_id = aion_core::RunId::new_v4();
794        let mut recorder = Recorder::new(workflow_id.clone(), store);
795        recorder
796            .record_workflow_started(
797                chrono::Utc::now(),
798                crate::durability::WorkflowStartRecord {
799                    workflow_type: workflow_type.to_owned(),
800                    input: payload("input")?,
801                    run_id: run_id.clone(),
802                    parent_run_id: None,
803                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
804                },
805            )
806            .await?;
807        let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
808        let handle = WorkflowHandle::new(WorkflowHandleParts {
809            workflow_id: workflow_id.clone(),
810            run_id: run_id.clone(),
811            pid,
812            workflow_type: workflow_type.to_owned(),
813            namespace: String::from("default"),
814            loaded_version: ContentHash::from_bytes([9; 32]),
815            cached_status: WorkflowStatus::Running,
816            residency: HandleResidency::Resident,
817            recorder,
818            completion: CompletionNotifier::new(),
819        });
820        engine
821            .registry()
822            .insert((workflow_id, run_id), handle.clone())?;
823        Ok(handle)
824    }
825
826    #[tokio::test]
827    async fn start_then_cancel_records_started_then_cancelled()
828    -> Result<(), Box<dyn std::error::Error>> {
829        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
830        let engine =
831            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
832        let handle = engine
833            .start_workflow(
834                "checkout",
835                payload("input")?,
836                HashMap::new(),
837                String::from("default"),
838            )
839            .await?;
840
841        engine
842            .cancel(
843                handle.workflow_id(),
844                handle.run_id(),
845                "caller requested cancellation",
846            )
847            .await?;
848
849        let history = store.read_history(handle.workflow_id()).await?;
850        match history.as_slice() {
851            [
852                Event::WorkflowStarted { .. },
853                Event::WorkflowCancelled { reason, .. },
854            ] => {
855                assert_eq!(reason, "caller requested cancellation");
856            }
857            other => return Err(format!("expected started then cancelled, found {other:?}").into()),
858        }
859        engine.shutdown()?;
860        Ok(())
861    }
862
863    fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
864        EventEnvelope {
865            seq,
866            recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
867            workflow_id: workflow_id.clone(),
868        }
869    }
870
871    fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
872        Event::WorkflowStarted {
873            envelope: test_envelope(workflow_id, seq),
874            workflow_type: String::from("checkout"),
875            input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
876            run_id: RunId::new_v4(),
877            parent_run_id: None,
878            package_version: PackageVersion::new("a".repeat(64)),
879        }
880    }
881
882    fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
883        Event::TimerStarted {
884            envelope: test_envelope(workflow_id, seq),
885            timer_id: timer_id.clone(),
886            fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
887        }
888    }
889
890    fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
891        Event::TimerFired {
892            envelope: test_envelope(workflow_id, seq),
893            timer_id: timer_id.clone(),
894        }
895    }
896
897    fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
898        Event::TimerCancelled {
899            envelope: test_envelope(workflow_id, seq),
900            timer_id: timer_id.clone(),
901        }
902    }
903
904    #[test]
905    fn live_timers_lists_started_and_unterminated() {
906        let workflow_id = WorkflowId::new_v4();
907        let first = TimerId::anonymous(0);
908        let second = TimerId::anonymous(1);
909        let history = vec![
910            started_event(&workflow_id, 0),
911            timer_started_event(&workflow_id, 1, &first),
912            timer_started_event(&workflow_id, 2, &second),
913        ];
914        assert_eq!(
915            live_timers_in_active_segment(&history),
916            vec![first, second],
917            "both started, unterminated timers should be live, in start order"
918        );
919    }
920
921    #[test]
922    fn live_timers_excludes_fired_and_cancelled() {
923        let workflow_id = WorkflowId::new_v4();
924        let fired = TimerId::anonymous(0);
925        let cancelled = TimerId::anonymous(1);
926        let live = TimerId::anonymous(2);
927        let history = vec![
928            started_event(&workflow_id, 0),
929            timer_started_event(&workflow_id, 1, &fired),
930            timer_started_event(&workflow_id, 2, &cancelled),
931            timer_started_event(&workflow_id, 3, &live),
932            timer_fired_event(&workflow_id, 4, &fired),
933            timer_cancelled_event(&workflow_id, 5, &cancelled),
934        ];
935        assert_eq!(
936            live_timers_in_active_segment(&history),
937            vec![live],
938            "only the timer with no terminal event remains live"
939        );
940    }
941
942    #[test]
943    fn live_timers_dedups_repeated_start() {
944        let workflow_id = WorkflowId::new_v4();
945        let timer = TimerId::anonymous(0);
946        let history = vec![
947            started_event(&workflow_id, 0),
948            timer_started_event(&workflow_id, 1, &timer),
949            timer_started_event(&workflow_id, 2, &timer),
950        ];
951        assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
952    }
953
954    #[test]
955    fn live_timers_scopes_to_active_run_segment() {
956        // A timer started in a prior run (before a continue-as-new
957        // `WorkflowStarted`) must not be surfaced for the replacement run.
958        let workflow_id = WorkflowId::new_v4();
959        let prior_run = TimerId::anonymous(0);
960        let current_run = TimerId::anonymous(0);
961        let history = vec![
962            started_event(&workflow_id, 0),
963            timer_started_event(&workflow_id, 1, &prior_run),
964            started_event(&workflow_id, 2),
965            timer_started_event(&workflow_id, 3, &current_run),
966        ];
967        assert_eq!(
968            live_timers_in_active_segment(&history),
969            vec![current_run],
970            "only timers from the latest WorkflowStarted segment are live"
971        );
972    }
973
974    #[test]
975    fn live_timers_empty_history_is_empty() {
976        assert!(live_timers_in_active_segment(&[]).is_empty());
977    }
978
979    /// Build an engine whose runtime has the production timer NIF bridge
980    /// installed against the given store + registry, so `Engine::cancel`'s timer
981    /// cleanup exercises the real `TimerService` path (not a fake). Must be
982    /// called from within a tokio runtime (`Handle::current()`).
983    fn engine_with_timer_bridge(
984        store: Arc<dyn EventStore>,
985        registry: Arc<Registry>,
986    ) -> Result<Engine, EngineError> {
987        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
988        runtime.register_waiting_test_module("checkout_deployed", "run");
989        crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
990            runtime.nif_state(),
991            Arc::clone(&registry),
992            Arc::clone(&store),
993            tokio::runtime::Handle::current(),
994            crate::runtime::SignalDeliveryConfig::default(),
995        );
996        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
997        Ok(Engine::new(EngineComponents {
998            store,
999            visibility_store,
1000            runtime: Arc::new(runtime),
1001            catalog: workflow_catalog("checkout", "checkout_deployed"),
1002            registry,
1003            supervision: Arc::new(SupervisionTree::new()),
1004            delegated: DelegatedSeams::default(),
1005            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1006            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1007            visibility_reconciliation_task: None,
1008        }))
1009    }
1010
1011    /// Root-cause regression: cancelling a workflow with a live durable timer
1012    /// must record `TimerCancelled` (before the terminal `WorkflowCancelled`),
1013    /// so the timer is dead in history and recovery never fires it as an
1014    /// orphan. Drives the real `Engine::cancel` against a runtime with the
1015    /// production timer bridge installed.
1016    #[tokio::test(flavor = "multi_thread")]
1017    async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1018    -> Result<(), Box<dyn std::error::Error>> {
1019        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1020        let registry = Arc::new(Registry::default());
1021        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1022
1023        let handle = engine
1024            .start_workflow(
1025                "checkout",
1026                payload("input")?,
1027                HashMap::new(),
1028                String::from("default"),
1029            )
1030            .await?;
1031
1032        // Arm a live durable timer for the resident run and record its
1033        // `TimerStarted`, exactly as the resume-live handoff would in production.
1034        let timer_id = TimerId::anonymous(0);
1035        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1036        handle
1037            .recorder()
1038            .lock()
1039            .await
1040            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1041            .await?;
1042        let timer_service =
1043            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1044                .map_err(|error| format!("timer service unavailable: {error}"))?;
1045        timer_service
1046            .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1047            .await?;
1048
1049        engine
1050            .cancel(
1051                handle.workflow_id(),
1052                handle.run_id(),
1053                "caller requested cancellation",
1054            )
1055            .await?;
1056
1057        let history = store.read_history(handle.workflow_id()).await?;
1058        match history.as_slice() {
1059            [
1060                Event::WorkflowStarted { .. },
1061                Event::TimerStarted {
1062                    timer_id: started, ..
1063                },
1064                Event::TimerCancelled {
1065                    timer_id: cancelled,
1066                    ..
1067                },
1068                Event::WorkflowCancelled { reason, .. },
1069            ] => {
1070                assert_eq!(started, &timer_id);
1071                assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1072                assert_eq!(reason, "caller requested cancellation");
1073            }
1074            other => {
1075                return Err(format!(
1076                    "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1077                )
1078                .into());
1079            }
1080        }
1081        engine.shutdown()?;
1082        Ok(())
1083    }
1084
1085    /// All live timers (not just one) are cancelled, in start order, before the
1086    /// terminal `WorkflowCancelled`.
1087    #[tokio::test(flavor = "multi_thread")]
1088    async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1089        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1090        let registry = Arc::new(Registry::default());
1091        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1092        let handle = engine
1093            .start_workflow(
1094                "checkout",
1095                payload("input")?,
1096                HashMap::new(),
1097                String::from("default"),
1098            )
1099            .await?;
1100
1101        let first = TimerId::anonymous(0);
1102        let second = TimerId::anonymous(1);
1103        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1104        {
1105            let recorder = handle.recorder();
1106            let mut recorder = recorder.lock().await;
1107            recorder
1108                .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1109                .await?;
1110            recorder
1111                .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1112                .await?;
1113        }
1114
1115        engine
1116            .cancel(handle.workflow_id(), handle.run_id(), "stop")
1117            .await?;
1118
1119        let history = store.read_history(handle.workflow_id()).await?;
1120        match history.as_slice() {
1121            [
1122                Event::WorkflowStarted { .. },
1123                Event::TimerStarted {
1124                    timer_id: started_first,
1125                    ..
1126                },
1127                Event::TimerStarted {
1128                    timer_id: started_second,
1129                    ..
1130                },
1131                Event::TimerCancelled {
1132                    timer_id: cancelled_first,
1133                    ..
1134                },
1135                Event::TimerCancelled {
1136                    timer_id: cancelled_second,
1137                    ..
1138                },
1139                Event::WorkflowCancelled { .. },
1140            ] => {
1141                assert_eq!(started_first, &first);
1142                assert_eq!(started_second, &second);
1143                assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1144                assert_eq!(
1145                    cancelled_second, &second,
1146                    "second live timer cancelled second"
1147                );
1148            }
1149            other => {
1150                return Err(format!(
1151                    "expected two timer-cancels before workflow-cancel, found {other:?}"
1152                )
1153                .into());
1154            }
1155        }
1156        engine.shutdown()?;
1157        Ok(())
1158    }
1159
1160    /// End-to-end source-of-bug proof: a cancelled workflow leaves no orphan for
1161    /// startup recovery. With a past-due durable timer row (the exact shape that
1162    /// bricked startup before the fix), recovery surfaces no `UnknownWorkflow`
1163    /// and fires nothing — because cancel recorded `TimerCancelled`, so the
1164    /// timer is dead in history. Complements the committed `recover_due` defense
1165    /// test by proving the orphan is gone *at the source*.
1166    #[tokio::test(flavor = "multi_thread")]
1167    async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1168    -> Result<(), Box<dyn std::error::Error>> {
1169        let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1170        let store: Arc<dyn EventStore> = concrete.clone();
1171        let registry = Arc::new(Registry::default());
1172        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1173        let handle = engine
1174            .start_workflow(
1175                "checkout",
1176                payload("input")?,
1177                HashMap::new(),
1178                String::from("default"),
1179            )
1180            .await?;
1181        let workflow_id = handle.workflow_id().clone();
1182
1183        // A live timer whose durable row is already past-due, inserted directly
1184        // (no wheel arm, so nothing races the cancel).
1185        let timer_id = TimerId::anonymous(0);
1186        let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1187        handle
1188            .recorder()
1189            .lock()
1190            .await
1191            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1192            .await?;
1193        concrete
1194            .schedule_timer(&workflow_id, &timer_id, fire_at)
1195            .await?;
1196
1197        let timer_service =
1198            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1199                .map_err(|error| format!("timer service unavailable: {error}"))?;
1200
1201        engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1202
1203        // Cancel removed the workflow from the registry and the durable row is
1204        // now past-due — exactly the orphan scenario. Recovery must handle it
1205        // cleanly: the recorded `TimerCancelled` makes `fire_timer` a no-op, so
1206        // no `TimerFired` and (critically) no `UnknownWorkflow`.
1207        let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1208        TimerRecovery::new(readable, timer_service, Duration::ZERO)
1209            .recover_on_startup(chrono::Utc::now())
1210            .await?;
1211
1212        let history = concrete.read_history(&workflow_id).await?;
1213        assert!(
1214            !history
1215                .iter()
1216                .any(|event| matches!(event, Event::TimerFired { .. })),
1217            "no timer should fire for a cancelled workflow during recovery"
1218        );
1219        assert!(
1220            history
1221                .iter()
1222                .any(|event| matches!(event, Event::TimerCancelled { .. })),
1223            "cancel must have recorded TimerCancelled at the source"
1224        );
1225        engine.shutdown()?;
1226        Ok(())
1227    }
1228
1229    #[tokio::test]
1230    async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1231        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1232        let engine =
1233            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1234        let handle = engine
1235            .start_workflow(
1236                "checkout",
1237                payload("input")?,
1238                HashMap::new(),
1239                String::from("default"),
1240            )
1241            .await?;
1242        let result_payload = payload("result")?;
1243
1244        terminate::complete(
1245            termination_context(&engine),
1246            handle.workflow_id(),
1247            handle.run_id(),
1248            result_payload.clone(),
1249        )
1250        .await?;
1251
1252        assert_eq!(
1253            engine.result(handle.workflow_id(), handle.run_id()).await?,
1254            Ok(result_payload)
1255        );
1256        engine.shutdown()?;
1257        Ok(())
1258    }
1259
1260    #[tokio::test]
1261    async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1262        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1263        let engine =
1264            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1265        let handle = engine
1266            .start_workflow(
1267                "checkout",
1268                payload("input")?,
1269                HashMap::new(),
1270                String::from("default"),
1271            )
1272            .await?;
1273        let error = workflow_error("workflow failed");
1274
1275        terminate::fail(
1276            termination_context(&engine),
1277            handle.workflow_id(),
1278            handle.run_id(),
1279            error.clone(),
1280        )
1281        .await?;
1282
1283        assert_eq!(
1284            engine.result(handle.workflow_id(), handle.run_id()).await?,
1285            Err(error)
1286        );
1287        engine.shutdown()?;
1288        Ok(())
1289    }
1290
1291    #[tokio::test]
1292    async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1293        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1294        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1295        let workflow_id = aion_core::WorkflowId::new_v4();
1296        let run_id = aion_core::RunId::new_v4();
1297
1298        let result = engine.result(&workflow_id, &run_id).await;
1299
1300        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1301        engine.shutdown()?;
1302        Ok(())
1303    }
1304
1305    #[tokio::test]
1306    async fn continue_as_new_unknown_workflow_returns_not_found()
1307    -> Result<(), Box<dyn std::error::Error>> {
1308        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1309        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1310        let workflow_id = aion_core::WorkflowId::new_v4();
1311        let run_id = aion_core::RunId::new_v4();
1312
1313        let result = engine
1314            .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1315            .await;
1316
1317        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1318        engine.shutdown()?;
1319        Ok(())
1320    }
1321
1322    #[tokio::test]
1323    async fn list_workflows_merges_live_and_terminal_without_duplicates()
1324    -> Result<(), Box<dyn std::error::Error>> {
1325        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1326        let engine =
1327            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1328        let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1329        let completed = engine
1330            .start_workflow(
1331                "checkout",
1332                payload("input")?,
1333                HashMap::new(),
1334                String::from("default"),
1335            )
1336            .await?;
1337        terminate::complete(
1338            termination_context(&engine),
1339            completed.workflow_id(),
1340            completed.run_id(),
1341            payload("result")?,
1342        )
1343        .await?;
1344
1345        let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1346        assert_eq!(summaries.len(), 2);
1347        assert!(summaries.iter().any(|summary| {
1348            &summary.workflow_id == running.workflow_id()
1349                && summary.status == WorkflowStatus::Running
1350        }));
1351        assert!(summaries.iter().any(|summary| {
1352            &summary.workflow_id == completed.workflow_id()
1353                && summary.status == WorkflowStatus::Completed
1354        }));
1355
1356        let completed_only = engine
1357            .list_workflows(WorkflowFilter {
1358                status: Some(WorkflowStatus::Completed),
1359                ..WorkflowFilter::default()
1360            })
1361            .await?;
1362        assert_eq!(completed_only.len(), 1);
1363        assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1364        engine.shutdown()?;
1365        Ok(())
1366    }
1367
1368    #[tokio::test]
1369    async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1370        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1371        let engine =
1372            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1373        let handle = engine
1374            .start_workflow(
1375                "checkout",
1376                payload("input")?,
1377                HashMap::new(),
1378                String::from("default"),
1379            )
1380            .await?;
1381        terminate::complete(
1382            termination_context(&engine),
1383            handle.workflow_id(),
1384            handle.run_id(),
1385            payload("result")?,
1386        )
1387        .await?;
1388
1389        engine.shutdown()?;
1390        let result = engine
1391            .start_workflow(
1392                "checkout",
1393                payload("after-shutdown")?,
1394                HashMap::new(),
1395                String::from("default"),
1396            )
1397            .await;
1398
1399        assert!(matches!(result, Err(EngineError::ShuttingDown)));
1400        Ok(())
1401    }
1402
1403    #[tokio::test]
1404    async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1405        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1406        let engine =
1407            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1408        let handle = engine
1409            .start_workflow(
1410                "checkout",
1411                payload("input")?,
1412                HashMap::new(),
1413                String::from("default"),
1414            )
1415            .await?;
1416        terminate::complete(
1417            termination_context(&engine),
1418            handle.workflow_id(),
1419            handle.run_id(),
1420            payload("result")?,
1421        )
1422        .await?;
1423
1424        engine.shutdown()?;
1425        let second = engine.shutdown();
1426
1427        assert!(
1428            second.is_ok(),
1429            "double shutdown should succeed; got {second:?}"
1430        );
1431        Ok(())
1432    }
1433
1434    #[tokio::test]
1435    async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1436        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1437        let engine =
1438            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1439        let handle = engine
1440            .start_workflow(
1441                "checkout",
1442                payload("input")?,
1443                HashMap::new(),
1444                String::from("default"),
1445            )
1446            .await?;
1447        terminate::complete(
1448            termination_context(&engine),
1449            handle.workflow_id(),
1450            handle.run_id(),
1451            payload("result")?,
1452        )
1453        .await?;
1454        engine.shutdown()?;
1455
1456        let config = aion_core::ScheduleConfig {
1457            trigger: aion_core::TriggerSpec::Interval {
1458                period: Duration::from_secs(60),
1459            },
1460            overlap_policy: aion_core::OverlapPolicy::Skip,
1461            catch_up_policy: aion_core::CatchUpPolicy::Skip,
1462            workflow_type: String::from("checkout"),
1463            input: payload("scheduled")?,
1464            search_attributes: HashMap::new(),
1465        };
1466        let result = engine.create_schedule(config).await;
1467
1468        assert!(
1469            matches!(result, Err(EngineError::ShuttingDown)),
1470            "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1471        );
1472        Ok(())
1473    }
1474}