Skip to main content

aion/engine/
builder.rs

1//! `EngineBuilder` and build wiring.
2
3use std::{num::NonZeroUsize, path::PathBuf, sync::Arc, time::Duration};
4
5use chrono::Utc;
6
7use aion_core::SearchAttributeSchema;
8use aion_package::{ExtractionLimits, Package};
9use aion_store::visibility::VisibilityStore;
10use aion_store::{EventStore, InMemoryStore};
11
12use crate::{
13    EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig, SupervisionTree,
14    WorkflowCatalog,
15    activity::bridge::ActivityDispatcher,
16    durability::ActiveWorkflowRecoverySeam,
17    runtime::{
18        ChildNifBridge, ChildNifBridgeParts, NifEntry, NifRegistration, install_child_nif_bridge,
19        install_nif_runtime_context, install_query_bridge, install_signal_nif_bridge,
20        nif_determinism::{NifContextSource, install_nif_context_source},
21    },
22    signal::SignalResumeHandoff,
23};
24
25use super::api::{Engine, EngineComponents};
26use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
27use super::seams::{
28    SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
29};
30use super::startup::{
31    StartupRecoveryContext, recover_active_workflows_on_startup, recover_timers_on_startup,
32};
33
34/// Source for a workflow package collected before `build()` performs fallible
35/// loading and runtime registration.
36#[derive(Clone, Debug)]
37pub enum WorkflowPackageSource {
38    /// Load a package from this `.aion` archive path during `build()`.
39    Path(PathBuf),
40    /// Use an already-loaded package value.
41    Package(Box<Package>),
42}
43
44/// Install the engine-scoped NIF seams that are available before delegated
45/// seams exist: runtime context, timer bridge, deterministic context source,
46/// query bridge, and the optional activity dispatcher.
47///
48/// Returns the query mailbox engine handle installed in the query bridge, so
49/// `build()` can wire the concrete query-dispatch seam over the same
50/// delivery path the NIF-side `dispatch_query` uses.
51fn install_engine_nif_seams(
52    nif_state: &Arc<crate::runtime::EngineNifState>,
53    registry: &Arc<Registry>,
54    store: &Arc<dyn EventStore>,
55    runtime: &Arc<RuntimeHandle>,
56    activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
57    query_timeout: Option<Duration>,
58) -> Arc<dyn crate::engine_seam::EngineHandle> {
59    install_nif_runtime_context(
60        nif_state,
61        Arc::clone(registry),
62        Arc::clone(runtime),
63        tokio::runtime::Handle::current(),
64    );
65    crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
66        nif_state,
67        Arc::clone(registry),
68        Arc::clone(store),
69        tokio::runtime::Handle::current(),
70        runtime.signal_delivery(),
71    );
72    install_nif_context_source(
73        nif_state,
74        Arc::new(NifContextSource::new(
75            Arc::clone(registry),
76            tokio::runtime::Handle::current(),
77            Arc::clone(store),
78            runtime.signal_delivery(),
79        )),
80    );
81    let query_mailbox_engine = install_query_bridge(
82        nif_state,
83        Arc::clone(registry),
84        runtime,
85        tokio::runtime::Handle::current(),
86        query_timeout,
87    );
88    if let Some(dispatcher) = activity_dispatcher {
89        nif_state.set_activity_dispatcher(dispatcher);
90    }
91    query_mailbox_engine
92}
93
94/// Assemble the startup catalog: persisted runtime deploys reload first
95/// (with their persisted route pointers), then explicit operator-supplied
96/// sources load on top.
97///
98/// The order is the routing-intent precedence: a package named explicitly at
99/// THIS boot (`--workflow-package` / builder source) is the operator's newest
100/// instruction and wins the route for its type, while every persisted deploy
101/// still reloads so startup recovery — which runs after this and resolves
102/// each run's recorded pinned version — finds every version it needs.
103/// Operator-file sources are not persisted; only the runtime deploy seam
104/// writes package rows.
105async fn assemble_startup_catalog(
106    runtime: &RuntimeHandle,
107    store: &dyn EventStore,
108    sources: Vec<WorkflowPackageSource>,
109) -> Result<Arc<WorkflowCatalog>, EngineError> {
110    let catalog = Arc::new(WorkflowCatalog::new());
111    crate::loader::persistence::reload_persisted_packages(runtime, catalog.as_ref(), store).await?;
112    for source in sources {
113        let package = package_from_source(source)?;
114        let outcome = catalog.load_package(runtime, &package).await?;
115        tracing::info!(
116            workflow_type = outcome.record.workflow_type(),
117            content_hash = %outcome.record.version(),
118            freshly_loaded = outcome.freshly_loaded,
119            "loaded workflow package {}",
120            outcome.record.workflow_type()
121        );
122    }
123    Ok(catalog)
124}
125
126impl From<Package> for WorkflowPackageSource {
127    fn from(package: Package) -> Self {
128        Self::Package(Box::new(package))
129    }
130}
131
132fn spawn_visibility_reconciliation_task(
133    interval: Duration,
134    store: Arc<dyn EventStore>,
135    visibility_store: Arc<dyn VisibilityStore>,
136) -> tokio::task::JoinHandle<()> {
137    tokio::spawn(async move {
138        loop {
139            tokio::time::sleep(interval).await;
140            if let Err(error) = crate::lifecycle::visibility::reconcile_visibility(
141                Arc::clone(&store),
142                Arc::clone(&visibility_store),
143            )
144            .await
145            {
146                tracing::warn!(
147                    error = %error,
148                    "periodic visibility reconciliation failed; crash-consistency window may remain until a later reconciliation repairs visibility"
149                );
150            }
151        }
152    })
153}
154
155/// Apply owned-shard scoping to the store BEFORE any recovery or enumeration
156/// reads it, so a multi-shard node recovers only its shards.
157///
158/// `None` leaves the store untouched — the single-node default, where the store
159/// owns ALL shards and boot is byte-identical to today (the scoping hook is
160/// never called). `Some(set)` forwards through any store decorator to the
161/// sharded backend; a single-shard backend ignores it.
162fn apply_owned_shards(store: &dyn EventStore, owned_shards: Option<&[usize]>) {
163    if let Some(shards) = owned_shards {
164        store.set_owned_shards(Some(shards));
165    }
166}
167
168/// Win the per-shard election and become the live owner of each owned shard
169/// BEFORE startup recovery reads them (SS-2).
170///
171/// Ordering matters: this runs after [`apply_owned_shards`] (so the store is
172/// already scoped to this node's shards) and BEFORE
173/// [`recover_active_workflows_on_startup`], so a distributed backend's
174/// `become_live` union-merge has made every committed write on those shards
175/// locally present before recovery enumerates them. The election is driven
176/// through the type-erased [`ReadableEventStore::acquire_owned_shards`] seam,
177/// whose distributed implementation runs the blocking coordinator on a bare
178/// off-runtime thread — so calling it from this async `build()` honours
179/// haematite's no-blocking-election-inside-an-async-context constraint.
180///
181/// `None` (the single-node default) skips election entirely, and the seam is a
182/// no-op for every non-distributed backend even when a shard set is configured,
183/// so boot stays byte-identical to today.
184fn acquire_owned_shards(
185    store: &dyn EventStore,
186    owned_shards: Option<&[usize]>,
187) -> Result<(), EngineError> {
188    if let Some(shards) = owned_shards {
189        store.acquire_owned_shards(shards)?;
190    }
191    Ok(())
192}
193
194/// Spawn the periodic visibility reconciliation task when an interval is
195/// configured, returning its join handle; otherwise return `None`.
196fn maybe_spawn_visibility_reconciliation(
197    interval: Option<Duration>,
198    store: &Arc<dyn EventStore>,
199    visibility_store: &Arc<dyn VisibilityStore>,
200) -> Option<tokio::task::JoinHandle<()>> {
201    interval.map(|interval| {
202        spawn_visibility_reconciliation_task(
203            interval,
204            Arc::clone(store),
205            Arc::clone(visibility_store),
206        )
207    })
208}
209
210impl From<PathBuf> for WorkflowPackageSource {
211    fn from(path: PathBuf) -> Self {
212        Self::Path(path)
213    }
214}
215
216impl From<&std::path::Path> for WorkflowPackageSource {
217    fn from(path: &std::path::Path) -> Self {
218        Self::Path(path.to_path_buf())
219    }
220}
221
222impl From<&str> for WorkflowPackageSource {
223    fn from(path: &str) -> Self {
224        Self::Path(PathBuf::from(path))
225    }
226}
227
228impl From<String> for WorkflowPackageSource {
229    fn from(path: String) -> Self {
230        Self::Path(PathBuf::from(path))
231    }
232}
233
234/// Tracks which optional engine seams the caller explicitly overrode, so
235/// `build()` can detect mutually-exclusive configuration (e.g. an event
236/// publisher set both directly and via event streaming). Grouped so the builder
237/// keeps its boolean configuration flags few and named.
238#[derive(Default)]
239struct SeamOverrides {
240    /// The caller installed an explicit event-publisher seam.
241    event_publisher: bool,
242    /// The caller installed an explicit query-service seam.
243    query_service: bool,
244}
245
246/// Builder for the embedded, transport-agnostic workflow engine.
247pub struct EngineBuilder {
248    store: Option<Arc<dyn EventStore>>,
249    visibility_store: Option<Arc<dyn VisibilityStore>>,
250    scheduler_threads: Option<usize>,
251    signal_delivery: SignalDeliveryConfig,
252    outbox_enabled: bool,
253    bootstrap_schedule_coordinator: bool,
254    owned_shards: Option<Vec<usize>>,
255    workflow_sources: Vec<WorkflowPackageSource>,
256    host_nifs: Vec<NifEntry>,
257    recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
258    delegated: DelegatedSeams,
259    signal_router_factory: Option<SignalRouterFactory>,
260    activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
261    active_registry: Option<Arc<Registry>>,
262    visibility_reconciliation_interval: Option<Duration>,
263    search_attribute_schema: SearchAttributeSchema,
264    event_streaming_capacity: Option<NonZeroUsize>,
265    query_timeout: Option<Duration>,
266    seam_overrides: SeamOverrides,
267}
268
269impl Default for EngineBuilder {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl EngineBuilder {
276    /// Create a builder with no store, no scheduler-thread override, no loaded
277    /// workflows, and no host NIFs.
278    #[must_use]
279    pub fn new() -> Self {
280        Self {
281            store: None,
282            visibility_store: None,
283            scheduler_threads: None,
284            signal_delivery: SignalDeliveryConfig::default(),
285            outbox_enabled: false,
286            // The only field that defaults true: single-node engines seed the
287            // schedule coordinator. A multi-node deployment disables it on nodes
288            // that do not own the coordinator's shard (see the builder method).
289            bootstrap_schedule_coordinator: true,
290            // No shard restriction by default: the store owns ALL shards, which
291            // is byte-identical to single-node behaviour. `build()` only ever
292            // touches owned-shard scoping when a deployment sets this.
293            owned_shards: None,
294            workflow_sources: Vec::new(),
295            host_nifs: Vec::new(),
296            recovery: None,
297            delegated: DelegatedSeams::default(),
298            signal_router_factory: None,
299            activity_dispatcher: None,
300            active_registry: None,
301            visibility_reconciliation_interval: None,
302            search_attribute_schema: SearchAttributeSchema::new(),
303            event_streaming_capacity: None,
304            query_timeout: None,
305            seam_overrides: SeamOverrides::default(),
306        }
307    }
308
309    /// Record the caller-supplied workflow query reply timeout.
310    ///
311    /// Setting a timeout installs the concrete query-dispatch seam during
312    /// `build()` (unless [`Self::query_service`] overrides it) and enables
313    /// the in-engine `dispatch_query` NIF. There is no default: without this
314    /// call the query seam stays deferred and `Engine::query` fails typed
315    /// with its "not configured" error.
316    #[must_use]
317    pub const fn query_timeout(mut self, timeout: Duration) -> Self {
318        self.query_timeout = Some(timeout);
319        self
320    }
321
322    /// Inspect the configured workflow query reply timeout.
323    #[must_use]
324    pub const fn configured_query_timeout(&self) -> Option<Duration> {
325        self.query_timeout
326    }
327
328    /// Opt in to live event streaming with a caller-provided broadcast capacity.
329    ///
330    /// `build()` wraps the configured store in a
331    /// [`PublishingEventStore`](crate::publish::PublishingEventStore) before any
332    /// recorder, recovery, or NIF bridge captures the store — so every
333    /// successful append publishes — and installs the matching
334    /// [`BroadcastEventPublisher`](crate::publish::BroadcastEventPublisher) as
335    /// the event-publisher seam behind [`Engine::subscribe`]. Without this call
336    /// the deferred publisher remains installed and subscriptions are empty.
337    #[must_use]
338    pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
339        self.event_streaming_capacity = Some(capacity);
340        self
341    }
342
343    /// Supply the search attribute schema validating every recorded attribute.
344    ///
345    /// The default schema is empty, which rejects all search attributes: a
346    /// deployment must declare each attribute name and type before workflows
347    /// can record values for it.
348    #[must_use]
349    pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
350        self.search_attribute_schema = schema;
351        self
352    }
353
354    /// Supply the event store used by the engine.
355    #[must_use]
356    pub fn store<S>(mut self, store: S) -> Self
357    where
358        S: EventStore,
359    {
360        self.store = Some(Arc::new(store));
361        self
362    }
363
364    /// Supply an already type-erased event store.
365    #[must_use]
366    pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
367        self.store = Some(store);
368        self
369    }
370
371    /// Supply the visibility store used by the engine for workflow projections.
372    #[must_use]
373    pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
374    where
375        S: VisibilityStore,
376    {
377        self.visibility_store = Some(Arc::new(visibility_store));
378        self
379    }
380
381    /// Supply an already type-erased visibility store.
382    #[must_use]
383    pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
384        self.visibility_store = Some(visibility_store);
385        self
386    }
387
388    /// Explicitly opt in to an ephemeral in-memory visibility store.
389    ///
390    /// This is intended for tests and local scenarios that do not need durable
391    /// visibility projections. Visibility data stored this way does not survive
392    /// process restarts.
393    #[must_use]
394    pub fn in_memory_visibility(mut self) -> Self {
395        self.visibility_store = Some(Arc::new(InMemoryStore::default()));
396        self
397    }
398
399    /// Record the caller-supplied scheduler thread count.
400    ///
401    /// If this setter is never called, `None` is passed through to beamr.
402    #[must_use]
403    pub const fn scheduler_threads(mut self, threads: usize) -> Self {
404        self.scheduler_threads = Some(threads);
405        self
406    }
407
408    /// Record the caller-supplied periodic visibility reconciliation interval.
409    ///
410    /// If this setter is never called, no periodic background reconciliation task is spawned.
411    #[must_use]
412    pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
413        self.visibility_reconciliation_interval = Some(interval);
414        self
415    }
416
417    /// Record the caller-supplied signal delivery readiness and retry policy.
418    #[must_use]
419    pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
420        self.signal_delivery = signal_delivery;
421        self
422    }
423
424    /// Record whether the durable-outbox fan-out dispatch path is enabled.
425    #[must_use]
426    pub fn outbox_enabled(mut self, enabled: bool) -> Self {
427        self.outbox_enabled = enabled;
428        self
429    }
430
431    /// Control whether `build()` seeds the schedule-coordinator history.
432    ///
433    /// Default `true` (single-node). Under multi-shard active-active the
434    /// coordinator stream is owned by exactly ONE shard; a deployment sets this
435    /// `false` on every node that does NOT own that shard, so only the owner
436    /// seeds (and serves) it — a non-owner would otherwise try to write the
437    /// coordinator stream and race or fence the real owner.
438    #[must_use]
439    pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
440        self.bootstrap_schedule_coordinator = enabled;
441        self
442    }
443
444    /// Restrict this engine's store to the distribution shards this node owns.
445    ///
446    /// Under multi-shard active-active a node serves only a SUBSET of the
447    /// cluster's shards. `build()` calls
448    /// [`ReadableEventStore::set_owned_shards`](aion_store::ReadableEventStore::set_owned_shards)
449    /// with this set BEFORE startup recovery, so the node recovers and
450    /// enumerates only the workflows / timers / outbox rows that live on its
451    /// shards. The set is deduplicated and ordered by the store.
452    ///
453    /// Not calling this leaves the store owning ALL shards — the single-node
454    /// default, which is byte-identical to today's behaviour (`build()` never
455    /// touches the scoping hook). Single-shard backends (in-memory, libSQL)
456    /// ignore the call regardless, since they own everything unconditionally.
457    #[must_use]
458    pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
459        self.owned_shards = Some(shards.into_iter().collect());
460        self
461    }
462
463    /// Inspect the configured owned-shard set (`None` = own all shards).
464    #[must_use]
465    pub fn configured_owned_shards(&self) -> Option<&[usize]> {
466        self.owned_shards.as_deref()
467    }
468
469    /// Add one workflow package source to load during `build()`.
470    #[must_use]
471    pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
472        self.workflow_sources.push(source.into());
473        self
474    }
475
476    /// Add many workflow package sources to load during `build()`.
477    #[must_use]
478    pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
479    where
480        I: IntoIterator<Item = S>,
481        S: Into<WorkflowPackageSource>,
482    {
483        self.workflow_sources
484            .extend(sources.into_iter().map(Into::into));
485        self
486    }
487
488    /// Collect host-supplied NIF entries to install before workflow modules load.
489    #[must_use]
490    pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
491        self.host_nifs.extend(entries);
492        self
493    }
494
495    /// Override the AD recovery seam used while repopulating active workflows.
496    #[must_use]
497    pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
498        self.recovery = Some(recovery);
499        self
500    }
501
502    /// Use the production AD recovery seam created after runtime/package loading.
503    #[must_use]
504    pub fn production_recovery_seam(mut self) -> Self {
505        self.recovery = None;
506        self
507    }
508
509    /// Override the AT signal-routing seam.
510    #[must_use]
511    pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
512        self.signal_router_factory = None;
513        self.delegated = DelegatedSeams::new(
514            signal_router,
515            self.delegated.query_service_arc(),
516            self.delegated.event_publisher_arc(),
517        );
518        self
519    }
520
521    /// Override the AT signal-routing seam after the runtime is assembled.
522    #[must_use]
523    pub fn signal_router_factory<F>(mut self, factory: F) -> Self
524    where
525        F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
526            + Send
527            + Sync
528            + 'static,
529    {
530        self.signal_router_factory = Some(Arc::new(factory));
531        self
532    }
533
534    /// Override the AT query-dispatch seam.
535    ///
536    /// An explicit override wins over the concrete service that
537    /// [`Self::query_timeout`] would otherwise install during `build()`.
538    #[must_use]
539    pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
540        self.seam_overrides.query_service = true;
541        self.delegated = DelegatedSeams::new(
542            self.delegated.signal_router_arc(),
543            query_service,
544            self.delegated.event_publisher_arc(),
545        );
546        self
547    }
548
549    /// Override the AD/AT live event-publisher seam.
550    ///
551    /// Mutually exclusive with [`Self::event_streaming`], which installs the
552    /// broadcast publisher itself; configuring both fails `build()`.
553    #[must_use]
554    pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
555        self.seam_overrides.event_publisher = true;
556        self.delegated = DelegatedSeams::new(
557            self.delegated.signal_router_arc(),
558            self.delegated.query_service_arc(),
559            event_publisher,
560        );
561        self
562    }
563
564    /// Supply the activity dispatcher that backs activity dispatch NIFs.
565    ///
566    /// When set, the dispatcher is installed in the global bridge before
567    /// workflow modules are loaded. Without a dispatcher, `dispatch_activity`
568    /// returns an error to workflow code instead of crashing the process.
569    #[must_use]
570    pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
571        self.activity_dispatcher = Some(dispatcher);
572        self
573    }
574
575    /// Supply the active workflow registry used by the built engine.
576    ///
577    /// Server-owned dispatchers that run behind raw NIFs use this to correlate a
578    /// calling BEAM pid to the same workflow handle the engine registers.
579    #[must_use]
580    pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
581        self.active_registry = Some(registry);
582        self
583    }
584
585    /// Inspect the configured scheduler thread count.
586    #[must_use]
587    pub const fn scheduler_thread_count(&self) -> Option<usize> {
588        self.scheduler_threads
589    }
590
591    /// Inspect the configured periodic visibility reconciliation interval.
592    #[must_use]
593    pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
594        self.visibility_reconciliation_interval
595    }
596
597    /// Assemble the runtime configuration from the builder-supplied scheduler,
598    /// signal delivery, and outbox knobs.
599    fn runtime_config(&self) -> RuntimeConfig {
600        RuntimeConfig::new(self.scheduler_threads)
601            .with_signal_delivery(self.signal_delivery)
602            .with_outbox_enabled(self.outbox_enabled)
603    }
604
605    /// Construct the live engine.
606    ///
607    /// # Errors
608    ///
609    /// Returns typed [`EngineError`] variants for missing store, runtime startup,
610    /// NIF registration, package loading, store reads, registry/supervision lock
611    /// poison, or deferred AD recovery failures for active histories.
612    pub async fn build(self) -> Result<Engine, EngineError> {
613        let runtime_config = self.runtime_config();
614        let (store, streaming_publisher) = wrap_event_streaming(
615            self.store.ok_or(EngineError::MissingStore)?,
616            self.event_streaming_capacity,
617            self.seam_overrides.event_publisher,
618        )?;
619        let visibility_store = self
620            .visibility_store
621            .ok_or(EngineError::MissingVisibilityStore)?;
622
623        apply_owned_shards(store.as_ref(), self.owned_shards.as_deref());
624        // SS-2: become the fenced live owner of this node's shards BEFORE any
625        // recovery enumerates them, so a distributed backend's `become_live`
626        // union-merge has landed every committed write locally first. No-op for
627        // single-node / non-distributed backends, so default boot is unchanged.
628        acquire_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
629
630        let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
631
632        let mut nifs = NifRegistration::new();
633        nifs.add_engine_nifs().add_host_nifs(self.host_nifs);
634        runtime.install_nifs(nifs)?;
635
636        // Persisted runtime deploys must be resident before startup recovery
637        // below resolves any run's recorded pinned version — this is the
638        // restart half of the deploy durability promise.
639        let catalog =
640            assemble_startup_catalog(runtime.as_ref(), store.as_ref(), self.workflow_sources)
641                .await?;
642
643        let registry = self
644            .active_registry
645            .unwrap_or_else(|| Arc::new(Registry::default()));
646        let nif_state = Arc::clone(runtime.nif_state());
647        let query_mailbox_engine = install_engine_nif_seams(
648            &nif_state,
649            &registry,
650            &store,
651            &runtime,
652            self.activity_dispatcher,
653            self.query_timeout,
654        );
655        let supervision = Arc::new(SupervisionTree::new());
656        let search_attribute_schema = Arc::new(self.search_attribute_schema);
657        let signal_handoff = Arc::new(SignalResumeHandoff::new());
658
659        let delegated = assemble_delegated_seams(SeamAssembly {
660            configured: self.delegated,
661            signal_router_factory: self.signal_router_factory,
662            runtime: Arc::clone(&runtime),
663            signal_handoff: Arc::clone(&signal_handoff),
664            streaming_publisher,
665            query_mailbox_engine,
666            query_timeout: self.query_timeout,
667            query_service_overridden: self.seam_overrides.query_service,
668        });
669
670        install_signal_nif_bridge(
671            &nif_state,
672            Arc::new(crate::runtime::SignalNifBridge::new(
673                Arc::clone(&registry),
674                Arc::clone(&runtime),
675                tokio::runtime::Handle::current(),
676                delegated.signal_router_arc(),
677            )),
678        );
679        install_configured_child_nif_bridge(&ChildBridgeAssembly {
680            nif_state: &nif_state,
681            store: &store,
682            visibility_store: &visibility_store,
683            runtime: &runtime,
684            catalog: &catalog,
685            registry: &registry,
686            supervision: &supervision,
687            signal_handoff: &signal_handoff,
688            search_attribute_schema: &search_attribute_schema,
689            watch_backoff: self.signal_delivery,
690        })?;
691
692        // Startup recovery re-spawns active workflow processes, and those
693        // processes begin replaying on scheduler threads immediately. Replay
694        // re-executes workflow code through the engine NIFs, so every NIF
695        // bridge (signal, child) must be installed before the first recovered
696        // process can run, or an early replayed spawn_child/receive_signal
697        // call fails with a missing-bridge error.
698        recover_active_workflows_on_startup(StartupRecoveryContext {
699            store: Arc::clone(&store),
700            visibility_store: Arc::clone(&visibility_store),
701            runtime: Arc::clone(&runtime),
702            catalog: Arc::clone(&catalog),
703            registry: Arc::clone(&registry),
704            supervision: Arc::clone(&supervision),
705            recovery: self.recovery,
706            search_attribute_schema: Arc::clone(&search_attribute_schema),
707            bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
708        })
709        .await?;
710        recover_timers_on_startup(&nif_state, Arc::clone(&store)).await?;
711
712        let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
713            self.visibility_reconciliation_interval,
714            &store,
715            &visibility_store,
716        );
717
718        let engine = Engine::new(EngineComponents {
719            store,
720            visibility_store,
721            runtime,
722            catalog,
723            registry,
724            supervision,
725            delegated,
726            signal_handoff,
727            search_attribute_schema,
728            visibility_reconciliation_task,
729        });
730        engine.catchup_schedule_coordinator().await?;
731        engine.recover_schedules_on_startup(Utc::now()).await?;
732        Ok(engine)
733    }
734}
735
736/// Borrowed engine components assembled into the child NIF bridge.
737struct ChildBridgeAssembly<'a> {
738    nif_state: &'a Arc<crate::runtime::EngineNifState>,
739    store: &'a Arc<dyn EventStore>,
740    visibility_store: &'a Arc<dyn VisibilityStore>,
741    runtime: &'a Arc<RuntimeHandle>,
742    catalog: &'a Arc<WorkflowCatalog>,
743    registry: &'a Arc<Registry>,
744    supervision: &'a Arc<SupervisionTree>,
745    signal_handoff: &'a Arc<SignalResumeHandoff>,
746    search_attribute_schema: &'a Arc<aion_core::SearchAttributeSchema>,
747    /// The child-terminal watcher reuses the builder's delivery retry
748    /// policy for its registry-miss backoff windows.
749    watch_backoff: SignalDeliveryConfig,
750}
751
752fn install_configured_child_nif_bridge(
753    assembly: &ChildBridgeAssembly<'_>,
754) -> Result<(), EngineError> {
755    install_child_nif_bridge(
756        assembly.nif_state,
757        Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
758            store: Arc::clone(assembly.store),
759            visibility_store: Arc::clone(assembly.visibility_store),
760            runtime: Arc::clone(assembly.runtime),
761            catalog: Arc::clone(assembly.catalog),
762            registry: Arc::clone(assembly.registry),
763            supervision: Arc::clone(assembly.supervision),
764            signal_handoff: Arc::clone(assembly.signal_handoff),
765            search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
766            tokio_handle: tokio::runtime::Handle::current(),
767            watch_backoff: assembly.watch_backoff,
768        })?),
769    );
770    Ok(())
771}
772
773pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
774    match source {
775        WorkflowPackageSource::Path(path) => {
776            // Operator-local startup packages from config/CLI are trusted
777            // input; only the network deploy path extracts bounded.
778            Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
779                EngineError::Load {
780                    reason: format!(
781                        "failed to load workflow package `{}`: {error}",
782                        path.display()
783                    ),
784                }
785            })
786        }
787        WorkflowPackageSource::Package(package) => Ok(*package),
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
794
795    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
796    use aion_package::{
797        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
798        ManifestVersion, Package, PackageBuilder,
799    };
800    use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
801    use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
802    use chrono::Utc;
803    use futures::StreamExt;
804    use serde_json::json;
805
806    use crate::engine::api_schedule::{
807        schedule_coordinator_run_id, schedule_coordinator_workflow_id,
808        schedule_coordinator_workflow_type,
809    };
810    use crate::runtime::{Mfa, NifEntry};
811
812    use super::EngineBuilder;
813    use crate::EngineError;
814
815    fn payload() -> Result<Payload, aion_core::PayloadError> {
816        Payload::from_json(&json!({ "input": true }))
817    }
818
819    fn started(
820        workflow_id: &WorkflowId,
821        workflow_type: &str,
822    ) -> Result<Event, aion_core::PayloadError> {
823        Ok(Event::WorkflowStarted {
824            envelope: EventEnvelope {
825                seq: 1,
826                recorded_at: Utc::now(),
827                workflow_id: workflow_id.clone(),
828            },
829            workflow_type: workflow_type.to_owned(),
830            input: payload()?,
831            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
832            parent_run_id: None,
833            package_version: aion_core::PackageVersion::new("a".repeat(64)),
834        })
835    }
836
837    fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
838        Ok(Event::WorkflowCompleted {
839            envelope: EventEnvelope {
840                seq: 2,
841                recorded_at: Utc::now(),
842                workflow_id: workflow_id.clone(),
843            },
844            result: payload()?,
845        })
846    }
847
848    fn package_manifest() -> Manifest {
849        Manifest {
850            entry_module: "counter".to_owned(),
851            entry_function: "version".to_owned(),
852            input_schema: json!({ "type": "object" }),
853            output_schema: json!({ "type": "integer" }),
854            timeout: Duration::from_secs(30),
855            activities: vec![DeclaredActivity {
856                activity_type: "activity/test".to_owned(),
857            }],
858            version: ManifestVersion::new("test"),
859            format_version: CURRENT_FORMAT_VERSION,
860        }
861    }
862
863    fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
864        let temp_dir =
865            std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
866        std::fs::create_dir(&temp_dir)?;
867        let source_path = temp_dir.join("counter.erl");
868        let beam_path = temp_dir.join("counter.beam");
869        std::fs::write(
870            &source_path,
871            "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
872        )?;
873        let status = Command::new("erlc")
874            .arg("-o")
875            .arg(&temp_dir)
876            .arg(&source_path)
877            .status()?;
878        if !status.success() {
879            let cleanup_result = std::fs::remove_dir_all(&temp_dir);
880            drop(cleanup_result);
881            return Err(format!("erlc failed with status {status}").into());
882        }
883        let bytes = std::fs::read(beam_path)?;
884        std::fs::remove_dir_all(temp_dir)?;
885        Ok(bytes)
886    }
887
888    fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
889        let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
890        let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
891        Ok(Package::load_from_bytes(
892            archive,
893            ExtractionLimits::unbounded(),
894        )?)
895    }
896
897    fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
898        let path =
899            std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
900        PackageBuilder::new(package.manifest().clone(), package.beams().clone())
901            .write_to_path(&path)?;
902        Ok(path)
903    }
904
905    #[tokio::test]
906    async fn build_without_store_returns_missing_store() {
907        let error = EngineBuilder::new().build().await.err();
908
909        assert!(matches!(error, Some(EngineError::MissingStore)));
910    }
911
912    #[tokio::test]
913    async fn build_without_visibility_store_returns_missing_visibility_store() {
914        let error = EngineBuilder::new()
915            .store(InMemoryStore::default())
916            .build()
917            .await
918            .err();
919
920        assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
921    }
922
923    #[tokio::test]
924    async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
925    {
926        let engine = EngineBuilder::new()
927            .store(InMemoryStore::default())
928            .in_memory_visibility()
929            .build()
930            .await?;
931
932        engine.shutdown()?;
933        Ok(())
934    }
935
936    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
937        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
938    }
939
940    #[tokio::test]
941    async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
942    -> Result<(), Box<dyn std::error::Error>> {
943        let engine = EngineBuilder::new()
944            .store(InMemoryStore::default())
945            .in_memory_visibility()
946            .event_streaming(capacity(8)?)
947            .build()
948            .await?;
949        let workflow_id = WorkflowId::new_v4();
950        let mut subscription = engine.subscribe(crate::EventFilter {
951            workflow_id: Some(workflow_id.clone()),
952            run: None,
953            family: None,
954        });
955
956        // The production append path: a Recorder over the engine's store,
957        // which `event_streaming` wrapped before any recorder existed.
958        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
959        recorder
960            .record_workflow_started(
961                Utc::now(),
962                crate::durability::WorkflowStartRecord {
963                    workflow_type: "checkout".to_owned(),
964                    input: payload()?,
965                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
966                    parent_run_id: None,
967                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
968                },
969            )
970            .await?;
971
972        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
973            .await?
974            .ok_or("subscription ended without delivering the appended event")?;
975        let event = item?;
976        assert_eq!(event.workflow_id(), &workflow_id);
977        assert_eq!(event.seq(), 1);
978        assert!(matches!(event, Event::WorkflowStarted { .. }));
979        engine.shutdown()?;
980        Ok(())
981    }
982
983    #[tokio::test]
984    async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
985    -> Result<(), Box<dyn std::error::Error>> {
986        let engine = EngineBuilder::new()
987            .store(InMemoryStore::default())
988            .in_memory_visibility()
989            .build()
990            .await?;
991
992        let mut subscription = engine.subscribe(crate::EventFilter::default());
993        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
994
995        assert!(item.is_none(), "deferred publisher streams must be empty");
996        engine.shutdown()?;
997        Ok(())
998    }
999
1000    #[tokio::test]
1001    async fn event_streaming_conflicts_with_explicit_event_publisher()
1002    -> Result<(), Box<dyn std::error::Error>> {
1003        let error = EngineBuilder::new()
1004            .store(InMemoryStore::default())
1005            .in_memory_visibility()
1006            .event_publisher(Arc::new(crate::DeferredEventPublisher))
1007            .event_streaming(capacity(8)?)
1008            .build()
1009            .await
1010            .err();
1011
1012        assert!(matches!(
1013            error,
1014            Some(EngineError::ConflictingEventPublisher)
1015        ));
1016        Ok(())
1017    }
1018
1019    #[test]
1020    fn query_timeout_is_only_set_by_caller() {
1021        assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1022        assert_eq!(
1023            EngineBuilder::new()
1024                .query_timeout(Duration::from_secs(3))
1025                .configured_query_timeout(),
1026            Some(Duration::from_secs(3))
1027        );
1028    }
1029
1030    async fn insert_running_workflow(
1031        engine: &crate::Engine,
1032    ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1033        let workflow_id = WorkflowId::new_v4();
1034        let run_id = aion_core::RunId::new_v4();
1035        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1036        recorder
1037            .record_workflow_started(
1038                Utc::now(),
1039                crate::durability::WorkflowStartRecord {
1040                    workflow_type: "checkout".to_owned(),
1041                    input: payload()?,
1042                    run_id: run_id.clone(),
1043                    parent_run_id: None,
1044                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1045                },
1046            )
1047            .await?;
1048        let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1049            workflow_id: workflow_id.clone(),
1050            run_id: run_id.clone(),
1051            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1052            workflow_type: "checkout".to_owned(),
1053            namespace: String::from("default"),
1054            loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1055            cached_status: WorkflowStatus::Running,
1056            residency: crate::registry::HandleResidency::Resident,
1057            recorder,
1058            completion: crate::registry::CompletionNotifier::new(),
1059        });
1060        engine
1061            .registry()
1062            .insert((workflow_id.clone(), run_id.clone()), handle)?;
1063        Ok((workflow_id, run_id))
1064    }
1065
1066    #[tokio::test]
1067    async fn query_timeout_installs_the_concrete_query_seam()
1068    -> Result<(), Box<dyn std::error::Error>> {
1069        let engine = EngineBuilder::new()
1070            .store(InMemoryStore::default())
1071            .in_memory_visibility()
1072            .query_timeout(Duration::from_millis(250))
1073            .build()
1074            .await?;
1075        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1076
1077        // The concrete seam reached the query mailbox engine, which answers
1078        // an unregistered name with a typed UnknownQuery — the deferred seam
1079        // would have failed with its "not configured" runtime error instead.
1080        let result = engine.query(&workflow_id, &run_id, "state").await;
1081
1082        assert!(matches!(
1083            result,
1084            Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1085        ));
1086        engine.shutdown()?;
1087        Ok(())
1088    }
1089
1090    #[tokio::test]
1091    async fn without_query_timeout_the_query_seam_stays_deferred()
1092    -> Result<(), Box<dyn std::error::Error>> {
1093        let engine = EngineBuilder::new()
1094            .store(InMemoryStore::default())
1095            .in_memory_visibility()
1096            .build()
1097            .await?;
1098        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1099
1100        let result = engine.query(&workflow_id, &run_id, "state").await;
1101
1102        assert!(matches!(
1103            result,
1104            Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1105        ));
1106        engine.shutdown()?;
1107        Ok(())
1108    }
1109
1110    #[test]
1111    fn owned_shards_are_only_set_by_caller() {
1112        // The default builder configures NO shard restriction: `build()` never
1113        // touches the store's scoping hook, so single-node boot owns ALL shards
1114        // and is byte-identical to today.
1115        assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1116        assert_eq!(
1117            EngineBuilder::new()
1118                .owned_shards([2, 0, 2, 1])
1119                .configured_owned_shards(),
1120            Some([2, 0, 2, 1].as_slice())
1121        );
1122    }
1123
1124    #[test]
1125    fn scheduler_threads_are_only_set_by_caller() {
1126        assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1127        assert_eq!(
1128            EngineBuilder::new()
1129                .scheduler_threads(4)
1130                .scheduler_thread_count(),
1131            Some(4)
1132        );
1133    }
1134
1135    #[test]
1136    fn visibility_reconciliation_interval_is_only_set_by_caller() {
1137        let interval = Duration::from_millis(250);
1138
1139        assert_eq!(
1140            EngineBuilder::new().configured_visibility_reconciliation_interval(),
1141            None
1142        );
1143        assert_eq!(
1144            EngineBuilder::new()
1145                .visibility_reconciliation_interval(interval)
1146                .configured_visibility_reconciliation_interval(),
1147            Some(interval)
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn duplicate_host_nif_mfa_returns_typed_error() {
1153        let mfa = Mfa::new("host", "zero", 0);
1154        let error = EngineBuilder::new()
1155            .store(InMemoryStore::default())
1156            .in_memory_visibility()
1157            .register_nifs([
1158                NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1159                NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1160            ])
1161            .build()
1162            .await
1163            .err();
1164
1165        assert!(matches!(
1166            error,
1167            Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1168        ));
1169    }
1170
1171    #[tokio::test]
1172    async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1173    -> Result<(), EngineError> {
1174        let store = Arc::new(InMemoryStore::default());
1175        let engine = EngineBuilder::new()
1176            .store_arc(store.clone())
1177            .in_memory_visibility()
1178            .build()
1179            .await?;
1180
1181        assert!(engine.registry().list()?.is_empty());
1182        assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1183        assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1184
1185        let coordinator_id = schedule_coordinator_workflow_id();
1186        let active = store.list_active().await?;
1187        assert_eq!(active, vec![coordinator_id.clone()]);
1188        let history = store.read_history(&coordinator_id).await?;
1189        let [started] = history.as_slice() else {
1190            return Err(EngineError::Load {
1191                reason: format!(
1192                    "expected exactly one coordinator event, found {}",
1193                    history.len()
1194                ),
1195            });
1196        };
1197        match started {
1198            Event::WorkflowStarted {
1199                workflow_type,
1200                input,
1201                run_id,
1202                parent_run_id,
1203                ..
1204            } => {
1205                assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1206                assert_eq!(
1207                    input,
1208                    &Payload::from_json(&json!({})).map_err(|error| {
1209                        EngineError::Load {
1210                            reason: format!("failed to build expected payload: {error}"),
1211                        }
1212                    })?
1213                );
1214                assert_eq!(run_id, &schedule_coordinator_run_id());
1215                assert!(parent_run_id.is_none());
1216            }
1217            other => {
1218                return Err(EngineError::Load {
1219                    reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1220                });
1221            }
1222        }
1223
1224        engine.shutdown()?;
1225        let rebuilt = EngineBuilder::new()
1226            .store_arc(store.clone())
1227            .in_memory_visibility()
1228            .build()
1229            .await?;
1230        let rebuilt_history = store.read_history(&coordinator_id).await?;
1231        assert_eq!(rebuilt_history.len(), 1);
1232        rebuilt.shutdown()?;
1233
1234        Ok(())
1235    }
1236
1237    #[tokio::test]
1238    async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1239        let package = fixture_package()?;
1240        let version = package.content_hash().clone();
1241        let deployed_entry_module = package.deployed_entry_module();
1242
1243        let engine = EngineBuilder::new()
1244            .store(InMemoryStore::default())
1245            .in_memory_visibility()
1246            .load_workflows(package)
1247            .build()
1248            .await?;
1249
1250        let loaded = engine
1251            .workflow_catalog()
1252            .get("counter", &version)?
1253            .ok_or("loaded package record missing")?;
1254        assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1255        assert!(
1256            engine
1257                .runtime()
1258                .has_registered_module(&deployed_entry_module)
1259        );
1260        Ok(())
1261    }
1262
1263    #[tokio::test]
1264    async fn startup_reconciliation_backfills_completed_visibility()
1265    -> Result<(), Box<dyn std::error::Error>> {
1266        let store = Arc::new(InMemoryStore::default());
1267        let completed_id = WorkflowId::new_v4();
1268
1269        store
1270            .append(
1271                WriteToken::recorder(),
1272                &completed_id,
1273                &[
1274                    started(&completed_id, "billing")?,
1275                    completed(&completed_id)?,
1276                ],
1277                0,
1278            )
1279            .await?;
1280
1281        let engine = EngineBuilder::new()
1282            .store_arc(store.clone())
1283            .visibility_store_arc(store.clone())
1284            .build()
1285            .await?;
1286
1287        let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1288        let completed_summary = summaries
1289            .iter()
1290            .find(|summary| summary.workflow_id == completed_id)
1291            .ok_or("completed workflow missing from visibility")?;
1292
1293        assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1294        assert!(completed_summary.close_time.is_some());
1295        engine.shutdown()?;
1296        Ok(())
1297    }
1298
1299    #[tokio::test]
1300    async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1301    -> Result<(), Box<dyn std::error::Error>> {
1302        let store = Arc::new(InMemoryStore::default());
1303        let engine = EngineBuilder::new()
1304            .store_arc(store.clone())
1305            .visibility_store_arc(store.clone())
1306            .visibility_reconciliation_interval(Duration::from_millis(25))
1307            .build()
1308            .await?;
1309        let workflow_id = WorkflowId::new_v4();
1310
1311        store
1312            .append(
1313                WriteToken::recorder(),
1314                &workflow_id,
1315                &[started(&workflow_id, "checkout")?],
1316                0,
1317            )
1318            .await?;
1319
1320        tokio::time::timeout(Duration::from_secs(2), async {
1321            loop {
1322                let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1323                if summaries.iter().any(|summary| {
1324                    summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1325                }) {
1326                    return Ok::<(), aion_store::StoreError>(());
1327                }
1328                tokio::time::sleep(Duration::from_millis(10)).await;
1329            }
1330        })
1331        .await??;
1332
1333        engine.shutdown()?;
1334        Ok(())
1335    }
1336
1337    #[tokio::test]
1338    async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1339        let package = fixture_package()?;
1340        let version = package.content_hash().clone();
1341        let path = write_fixture_package(&package)?;
1342
1343        let engine = EngineBuilder::new()
1344            .store(InMemoryStore::default())
1345            .in_memory_visibility()
1346            .load_workflows(path.as_path())
1347            .build()
1348            .await?;
1349        std::fs::remove_file(path)?;
1350
1351        assert!(
1352            engine
1353                .workflow_catalog()
1354                .get("counter", &version)?
1355                .is_some()
1356        );
1357        Ok(())
1358    }
1359}