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