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            additional_workflows: Vec::new(),
861        }
862    }
863
864    fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
865        let temp_dir =
866            std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
867        std::fs::create_dir(&temp_dir)?;
868        let source_path = temp_dir.join("counter.erl");
869        let beam_path = temp_dir.join("counter.beam");
870        std::fs::write(
871            &source_path,
872            "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
873        )?;
874        let status = Command::new("erlc")
875            .arg("-o")
876            .arg(&temp_dir)
877            .arg(&source_path)
878            .status()?;
879        if !status.success() {
880            let cleanup_result = std::fs::remove_dir_all(&temp_dir);
881            drop(cleanup_result);
882            return Err(format!("erlc failed with status {status}").into());
883        }
884        let bytes = std::fs::read(beam_path)?;
885        std::fs::remove_dir_all(temp_dir)?;
886        Ok(bytes)
887    }
888
889    fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
890        let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
891        let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
892        Ok(Package::load_from_bytes(
893            archive,
894            ExtractionLimits::unbounded(),
895        )?)
896    }
897
898    fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
899        let path =
900            std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
901        PackageBuilder::new(package.manifest().clone(), package.beams().clone())
902            .write_to_path(&path)?;
903        Ok(path)
904    }
905
906    #[tokio::test]
907    async fn build_without_store_returns_missing_store() {
908        let error = EngineBuilder::new().build().await.err();
909
910        assert!(matches!(error, Some(EngineError::MissingStore)));
911    }
912
913    #[tokio::test]
914    async fn build_without_visibility_store_returns_missing_visibility_store() {
915        let error = EngineBuilder::new()
916            .store(InMemoryStore::default())
917            .build()
918            .await
919            .err();
920
921        assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
922    }
923
924    #[tokio::test]
925    async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
926    {
927        let engine = EngineBuilder::new()
928            .store(InMemoryStore::default())
929            .in_memory_visibility()
930            .build()
931            .await?;
932
933        engine.shutdown()?;
934        Ok(())
935    }
936
937    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
938        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
939    }
940
941    #[tokio::test]
942    async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
943    -> Result<(), Box<dyn std::error::Error>> {
944        let engine = EngineBuilder::new()
945            .store(InMemoryStore::default())
946            .in_memory_visibility()
947            .event_streaming(capacity(8)?)
948            .build()
949            .await?;
950        let workflow_id = WorkflowId::new_v4();
951        let mut subscription = engine.subscribe(crate::EventFilter {
952            workflow_id: Some(workflow_id.clone()),
953            run: None,
954            family: None,
955        });
956
957        // The production append path: a Recorder over the engine's store,
958        // which `event_streaming` wrapped before any recorder existed.
959        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
960        recorder
961            .record_workflow_started(
962                Utc::now(),
963                crate::durability::WorkflowStartRecord {
964                    workflow_type: "checkout".to_owned(),
965                    input: payload()?,
966                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
967                    parent_run_id: None,
968                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
969                },
970            )
971            .await?;
972
973        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
974            .await?
975            .ok_or("subscription ended without delivering the appended event")?;
976        let event = item?;
977        assert_eq!(event.workflow_id(), &workflow_id);
978        assert_eq!(event.seq(), 1);
979        assert!(matches!(event, Event::WorkflowStarted { .. }));
980        engine.shutdown()?;
981        Ok(())
982    }
983
984    #[tokio::test]
985    async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
986    -> Result<(), Box<dyn std::error::Error>> {
987        let engine = EngineBuilder::new()
988            .store(InMemoryStore::default())
989            .in_memory_visibility()
990            .build()
991            .await?;
992
993        let mut subscription = engine.subscribe(crate::EventFilter::default());
994        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
995
996        assert!(item.is_none(), "deferred publisher streams must be empty");
997        engine.shutdown()?;
998        Ok(())
999    }
1000
1001    #[tokio::test]
1002    async fn event_streaming_conflicts_with_explicit_event_publisher()
1003    -> Result<(), Box<dyn std::error::Error>> {
1004        let error = EngineBuilder::new()
1005            .store(InMemoryStore::default())
1006            .in_memory_visibility()
1007            .event_publisher(Arc::new(crate::DeferredEventPublisher))
1008            .event_streaming(capacity(8)?)
1009            .build()
1010            .await
1011            .err();
1012
1013        assert!(matches!(
1014            error,
1015            Some(EngineError::ConflictingEventPublisher)
1016        ));
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn query_timeout_is_only_set_by_caller() {
1022        assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1023        assert_eq!(
1024            EngineBuilder::new()
1025                .query_timeout(Duration::from_secs(3))
1026                .configured_query_timeout(),
1027            Some(Duration::from_secs(3))
1028        );
1029    }
1030
1031    async fn insert_running_workflow(
1032        engine: &crate::Engine,
1033    ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1034        let workflow_id = WorkflowId::new_v4();
1035        let run_id = aion_core::RunId::new_v4();
1036        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1037        recorder
1038            .record_workflow_started(
1039                Utc::now(),
1040                crate::durability::WorkflowStartRecord {
1041                    workflow_type: "checkout".to_owned(),
1042                    input: payload()?,
1043                    run_id: run_id.clone(),
1044                    parent_run_id: None,
1045                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1046                },
1047            )
1048            .await?;
1049        let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1050            workflow_id: workflow_id.clone(),
1051            run_id: run_id.clone(),
1052            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1053            workflow_type: "checkout".to_owned(),
1054            namespace: String::from("default"),
1055            loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1056            cached_status: WorkflowStatus::Running,
1057            residency: crate::registry::HandleResidency::Resident,
1058            recorder,
1059            completion: crate::registry::CompletionNotifier::new(),
1060        });
1061        engine
1062            .registry()
1063            .insert((workflow_id.clone(), run_id.clone()), handle)?;
1064        Ok((workflow_id, run_id))
1065    }
1066
1067    #[tokio::test]
1068    async fn query_timeout_installs_the_concrete_query_seam()
1069    -> Result<(), Box<dyn std::error::Error>> {
1070        let engine = EngineBuilder::new()
1071            .store(InMemoryStore::default())
1072            .in_memory_visibility()
1073            .query_timeout(Duration::from_millis(250))
1074            .build()
1075            .await?;
1076        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1077
1078        // The concrete seam reached the query mailbox engine, which answers
1079        // an unregistered name with a typed UnknownQuery — the deferred seam
1080        // would have failed with its "not configured" runtime error instead.
1081        let result = engine.query(&workflow_id, &run_id, "state").await;
1082
1083        assert!(matches!(
1084            result,
1085            Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1086        ));
1087        engine.shutdown()?;
1088        Ok(())
1089    }
1090
1091    #[tokio::test]
1092    async fn without_query_timeout_the_query_seam_stays_deferred()
1093    -> Result<(), Box<dyn std::error::Error>> {
1094        let engine = EngineBuilder::new()
1095            .store(InMemoryStore::default())
1096            .in_memory_visibility()
1097            .build()
1098            .await?;
1099        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1100
1101        let result = engine.query(&workflow_id, &run_id, "state").await;
1102
1103        assert!(matches!(
1104            result,
1105            Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1106        ));
1107        engine.shutdown()?;
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn owned_shards_are_only_set_by_caller() {
1113        // The default builder configures NO shard restriction: `build()` never
1114        // touches the store's scoping hook, so single-node boot owns ALL shards
1115        // and is byte-identical to today.
1116        assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1117        assert_eq!(
1118            EngineBuilder::new()
1119                .owned_shards([2, 0, 2, 1])
1120                .configured_owned_shards(),
1121            Some([2, 0, 2, 1].as_slice())
1122        );
1123    }
1124
1125    #[test]
1126    fn scheduler_threads_are_only_set_by_caller() {
1127        assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1128        assert_eq!(
1129            EngineBuilder::new()
1130                .scheduler_threads(4)
1131                .scheduler_thread_count(),
1132            Some(4)
1133        );
1134    }
1135
1136    #[test]
1137    fn visibility_reconciliation_interval_is_only_set_by_caller() {
1138        let interval = Duration::from_millis(250);
1139
1140        assert_eq!(
1141            EngineBuilder::new().configured_visibility_reconciliation_interval(),
1142            None
1143        );
1144        assert_eq!(
1145            EngineBuilder::new()
1146                .visibility_reconciliation_interval(interval)
1147                .configured_visibility_reconciliation_interval(),
1148            Some(interval)
1149        );
1150    }
1151
1152    #[tokio::test]
1153    async fn duplicate_host_nif_mfa_returns_typed_error() {
1154        let mfa = Mfa::new("host", "zero", 0);
1155        let error = EngineBuilder::new()
1156            .store(InMemoryStore::default())
1157            .in_memory_visibility()
1158            .register_nifs([
1159                NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1160                NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1161            ])
1162            .build()
1163            .await
1164            .err();
1165
1166        assert!(matches!(
1167            error,
1168            Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1169        ));
1170    }
1171
1172    #[tokio::test]
1173    async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1174    -> Result<(), EngineError> {
1175        let store = Arc::new(InMemoryStore::default());
1176        let engine = EngineBuilder::new()
1177            .store_arc(store.clone())
1178            .in_memory_visibility()
1179            .build()
1180            .await?;
1181
1182        assert!(engine.registry().list()?.is_empty());
1183        assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1184        assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1185
1186        let coordinator_id = schedule_coordinator_workflow_id();
1187        let active = store.list_active().await?;
1188        assert_eq!(active, vec![coordinator_id.clone()]);
1189        let history = store.read_history(&coordinator_id).await?;
1190        let [started] = history.as_slice() else {
1191            return Err(EngineError::Load {
1192                reason: format!(
1193                    "expected exactly one coordinator event, found {}",
1194                    history.len()
1195                ),
1196            });
1197        };
1198        match started {
1199            Event::WorkflowStarted {
1200                workflow_type,
1201                input,
1202                run_id,
1203                parent_run_id,
1204                ..
1205            } => {
1206                assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1207                assert_eq!(
1208                    input,
1209                    &Payload::from_json(&json!({})).map_err(|error| {
1210                        EngineError::Load {
1211                            reason: format!("failed to build expected payload: {error}"),
1212                        }
1213                    })?
1214                );
1215                assert_eq!(run_id, &schedule_coordinator_run_id());
1216                assert!(parent_run_id.is_none());
1217            }
1218            other => {
1219                return Err(EngineError::Load {
1220                    reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1221                });
1222            }
1223        }
1224
1225        engine.shutdown()?;
1226        let rebuilt = EngineBuilder::new()
1227            .store_arc(store.clone())
1228            .in_memory_visibility()
1229            .build()
1230            .await?;
1231        let rebuilt_history = store.read_history(&coordinator_id).await?;
1232        assert_eq!(rebuilt_history.len(), 1);
1233        rebuilt.shutdown()?;
1234
1235        Ok(())
1236    }
1237
1238    #[tokio::test]
1239    async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1240        let package = fixture_package()?;
1241        let version = package.content_hash().clone();
1242        let deployed_entry_module = package.deployed_entry_module();
1243
1244        let engine = EngineBuilder::new()
1245            .store(InMemoryStore::default())
1246            .in_memory_visibility()
1247            .load_workflows(package)
1248            .build()
1249            .await?;
1250
1251        let loaded = engine
1252            .workflow_catalog()
1253            .get("counter", &version)?
1254            .ok_or("loaded package record missing")?;
1255        assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1256        assert!(
1257            engine
1258                .runtime()
1259                .has_registered_module(&deployed_entry_module)
1260        );
1261        Ok(())
1262    }
1263
1264    #[tokio::test]
1265    async fn startup_reconciliation_backfills_completed_visibility()
1266    -> Result<(), Box<dyn std::error::Error>> {
1267        let store = Arc::new(InMemoryStore::default());
1268        let completed_id = WorkflowId::new_v4();
1269
1270        store
1271            .append(
1272                WriteToken::recorder(),
1273                &completed_id,
1274                &[
1275                    started(&completed_id, "billing")?,
1276                    completed(&completed_id)?,
1277                ],
1278                0,
1279            )
1280            .await?;
1281
1282        let engine = EngineBuilder::new()
1283            .store_arc(store.clone())
1284            .visibility_store_arc(store.clone())
1285            .build()
1286            .await?;
1287
1288        let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1289        let completed_summary = summaries
1290            .iter()
1291            .find(|summary| summary.workflow_id == completed_id)
1292            .ok_or("completed workflow missing from visibility")?;
1293
1294        assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1295        assert!(completed_summary.close_time.is_some());
1296        engine.shutdown()?;
1297        Ok(())
1298    }
1299
1300    #[tokio::test]
1301    async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1302    -> Result<(), Box<dyn std::error::Error>> {
1303        let store = Arc::new(InMemoryStore::default());
1304        let engine = EngineBuilder::new()
1305            .store_arc(store.clone())
1306            .visibility_store_arc(store.clone())
1307            .visibility_reconciliation_interval(Duration::from_millis(25))
1308            .build()
1309            .await?;
1310        let workflow_id = WorkflowId::new_v4();
1311
1312        store
1313            .append(
1314                WriteToken::recorder(),
1315                &workflow_id,
1316                &[started(&workflow_id, "checkout")?],
1317                0,
1318            )
1319            .await?;
1320
1321        tokio::time::timeout(Duration::from_secs(2), async {
1322            loop {
1323                let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1324                if summaries.iter().any(|summary| {
1325                    summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1326                }) {
1327                    return Ok::<(), aion_store::StoreError>(());
1328                }
1329                tokio::time::sleep(Duration::from_millis(10)).await;
1330            }
1331        })
1332        .await??;
1333
1334        engine.shutdown()?;
1335        Ok(())
1336    }
1337
1338    #[tokio::test]
1339    async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1340        let package = fixture_package()?;
1341        let version = package.content_hash().clone();
1342        let path = write_fixture_package(&package)?;
1343
1344        let engine = EngineBuilder::new()
1345            .store(InMemoryStore::default())
1346            .in_memory_visibility()
1347            .load_workflows(path.as_path())
1348            .build()
1349            .await?;
1350        std::fs::remove_file(path)?;
1351
1352        assert!(
1353            engine
1354                .workflow_catalog()
1355                .get("counter", &version)?
1356                .is_some()
1357        );
1358        Ok(())
1359    }
1360}