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
752/// Register the `WorkflowDeadlineHandler` on the timer bridge.
753///
754/// The handler holds the runtime weakly so the runtime → nif-state → bridge →
755/// handler chain never cycles back into the runtime (the documented
756/// cycle-avoidance the timer bridge observes with its `Weak<EngineNifState>`).
757///
758/// # Errors
759///
760/// Returns [`EngineError::Runtime`] when no timer bridge is installed.
761fn register_workflow_deadline_handler(
762    nif_state: &crate::runtime::EngineNifState,
763    runtime: &Arc<RuntimeHandle>,
764    store: &Arc<dyn EventStore>,
765    visibility_store: &Arc<dyn VisibilityStore>,
766    registry: &Arc<Registry>,
767) -> Result<(), EngineError> {
768    crate::runtime::nif_timer_bridge::register_deadline_handler(
769        nif_state,
770        Arc::new(crate::lifecycle::deadline::WorkflowDeadlineHandler::new(
771            Arc::downgrade(runtime),
772            Arc::clone(store),
773            Arc::clone(visibility_store),
774            Arc::clone(registry),
775        )),
776    )
777    .map_err(|error| EngineError::Runtime {
778        reason: format!("failed to register workflow deadline handler: {error}"),
779    })
780}
781
782fn install_configured_child_nif_bridge(
783    assembly: &ChildBridgeAssembly<'_>,
784) -> Result<(), EngineError> {
785    install_child_nif_bridge(
786        assembly.nif_state,
787        Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
788            store: Arc::clone(assembly.store),
789            visibility_store: Arc::clone(assembly.visibility_store),
790            runtime: Arc::clone(assembly.runtime),
791            catalog: Arc::clone(assembly.catalog),
792            registry: Arc::clone(assembly.registry),
793            supervision: Arc::clone(assembly.supervision),
794            signal_handoff: Arc::clone(assembly.signal_handoff),
795            search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
796            tokio_handle: tokio::runtime::Handle::current(),
797            watch_backoff: assembly.watch_backoff,
798        })?),
799    );
800    // Register the workflow-deadline handler here too: it needs the same
801    // teardown deps this assembly carries, and this runs before startup timer
802    // recovery, so an already-due `deadline:{run_id}` swept at boot routes to
803    // the engine rather than failing as an unhandled reserved fire.
804    register_workflow_deadline_handler(
805        assembly.nif_state,
806        assembly.runtime,
807        assembly.store,
808        assembly.visibility_store,
809        assembly.registry,
810    )
811}
812
813pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
814    match source {
815        WorkflowPackageSource::Path(path) => {
816            // Operator-local startup packages from config/CLI are trusted
817            // input; only the network deploy path extracts bounded.
818            Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
819                EngineError::Load {
820                    reason: format!(
821                        "failed to load workflow package `{}`: {error}",
822                        path.display()
823                    ),
824                }
825            })
826        }
827        WorkflowPackageSource::Package(package) => Ok(*package),
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
834
835    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
836    use aion_package::{
837        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
838        ManifestVersion, Package, PackageBuilder,
839    };
840    use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
841    use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
842    use chrono::Utc;
843    use futures::StreamExt;
844    use serde_json::json;
845
846    use crate::engine::api_schedule::{
847        schedule_coordinator_run_id, schedule_coordinator_workflow_id,
848        schedule_coordinator_workflow_type,
849    };
850    use crate::runtime::{Mfa, NifEntry};
851
852    use super::EngineBuilder;
853    use crate::EngineError;
854
855    fn payload() -> Result<Payload, aion_core::PayloadError> {
856        Payload::from_json(&json!({ "input": true }))
857    }
858
859    fn started(
860        workflow_id: &WorkflowId,
861        workflow_type: &str,
862    ) -> Result<Event, aion_core::PayloadError> {
863        Ok(Event::WorkflowStarted {
864            envelope: EventEnvelope {
865                seq: 1,
866                recorded_at: Utc::now(),
867                workflow_id: workflow_id.clone(),
868            },
869            workflow_type: workflow_type.to_owned(),
870            input: payload()?,
871            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
872            parent_run_id: None,
873            package_version: aion_core::PackageVersion::new("a".repeat(64)),
874        })
875    }
876
877    fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
878        Ok(Event::WorkflowCompleted {
879            envelope: EventEnvelope {
880                seq: 2,
881                recorded_at: Utc::now(),
882                workflow_id: workflow_id.clone(),
883            },
884            result: payload()?,
885        })
886    }
887
888    fn package_manifest() -> Manifest {
889        Manifest {
890            entry_module: "counter".to_owned(),
891            entry_function: "version".to_owned(),
892            input_schema: json!({ "type": "object" }),
893            output_schema: json!({ "type": "integer" }),
894            timeout: Some(Duration::from_secs(30)),
895            activities: vec![DeclaredActivity {
896                activity_type: "activity/test".to_owned(),
897            }],
898            version: ManifestVersion::new("test"),
899            format_version: CURRENT_FORMAT_VERSION,
900            additional_workflows: Vec::new(),
901        }
902    }
903
904    fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
905        let temp_dir =
906            std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
907        std::fs::create_dir(&temp_dir)?;
908        let source_path = temp_dir.join("counter.erl");
909        let beam_path = temp_dir.join("counter.beam");
910        std::fs::write(
911            &source_path,
912            "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
913        )?;
914        let status = Command::new("erlc")
915            .arg("-o")
916            .arg(&temp_dir)
917            .arg(&source_path)
918            .status()?;
919        if !status.success() {
920            let cleanup_result = std::fs::remove_dir_all(&temp_dir);
921            drop(cleanup_result);
922            return Err(format!("erlc failed with status {status}").into());
923        }
924        let bytes = std::fs::read(beam_path)?;
925        std::fs::remove_dir_all(temp_dir)?;
926        Ok(bytes)
927    }
928
929    fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
930        let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
931        let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
932        Ok(Package::load_from_bytes(
933            archive,
934            ExtractionLimits::unbounded(),
935        )?)
936    }
937
938    fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
939        let path =
940            std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
941        PackageBuilder::new(package.manifest().clone(), package.beams().clone())
942            .write_to_path(&path)?;
943        Ok(path)
944    }
945
946    #[tokio::test]
947    async fn build_without_store_returns_missing_store() {
948        let error = EngineBuilder::new().build().await.err();
949
950        assert!(matches!(error, Some(EngineError::MissingStore)));
951    }
952
953    #[tokio::test]
954    async fn build_without_visibility_store_returns_missing_visibility_store() {
955        let error = EngineBuilder::new()
956            .store(InMemoryStore::default())
957            .build()
958            .await
959            .err();
960
961        assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
962    }
963
964    #[tokio::test]
965    async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
966    {
967        let engine = EngineBuilder::new()
968            .store(InMemoryStore::default())
969            .in_memory_visibility()
970            .build()
971            .await?;
972
973        engine.shutdown()?;
974        Ok(())
975    }
976
977    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
978        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
979    }
980
981    #[tokio::test]
982    async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
983    -> Result<(), Box<dyn std::error::Error>> {
984        let engine = EngineBuilder::new()
985            .store(InMemoryStore::default())
986            .in_memory_visibility()
987            .event_streaming(capacity(8)?)
988            .build()
989            .await?;
990        let workflow_id = WorkflowId::new_v4();
991        let mut subscription = engine.subscribe(crate::EventFilter {
992            workflow_id: Some(workflow_id.clone()),
993            run: None,
994            family: None,
995        });
996
997        // The production append path: a Recorder over the engine's store,
998        // which `event_streaming` wrapped before any recorder existed.
999        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1000        recorder
1001            .record_workflow_started(
1002                Utc::now(),
1003                crate::durability::WorkflowStartRecord {
1004                    workflow_type: "checkout".to_owned(),
1005                    input: payload()?,
1006                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1007                    parent_run_id: None,
1008                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1009                },
1010            )
1011            .await?;
1012
1013        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1014            .await?
1015            .ok_or("subscription ended without delivering the appended event")?;
1016        let event = item?;
1017        assert_eq!(event.workflow_id(), &workflow_id);
1018        assert_eq!(event.seq(), 1);
1019        assert!(matches!(event, Event::WorkflowStarted { .. }));
1020        engine.shutdown()?;
1021        Ok(())
1022    }
1023
1024    #[tokio::test]
1025    async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1026    -> Result<(), Box<dyn std::error::Error>> {
1027        let engine = EngineBuilder::new()
1028            .store(InMemoryStore::default())
1029            .in_memory_visibility()
1030            .build()
1031            .await?;
1032
1033        let mut subscription = engine.subscribe(crate::EventFilter::default());
1034        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1035
1036        assert!(item.is_none(), "deferred publisher streams must be empty");
1037        engine.shutdown()?;
1038        Ok(())
1039    }
1040
1041    #[tokio::test]
1042    async fn event_streaming_conflicts_with_explicit_event_publisher()
1043    -> Result<(), Box<dyn std::error::Error>> {
1044        let error = EngineBuilder::new()
1045            .store(InMemoryStore::default())
1046            .in_memory_visibility()
1047            .event_publisher(Arc::new(crate::DeferredEventPublisher))
1048            .event_streaming(capacity(8)?)
1049            .build()
1050            .await
1051            .err();
1052
1053        assert!(matches!(
1054            error,
1055            Some(EngineError::ConflictingEventPublisher)
1056        ));
1057        Ok(())
1058    }
1059
1060    #[test]
1061    fn query_timeout_is_only_set_by_caller() {
1062        assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1063        assert_eq!(
1064            EngineBuilder::new()
1065                .query_timeout(Duration::from_secs(3))
1066                .configured_query_timeout(),
1067            Some(Duration::from_secs(3))
1068        );
1069    }
1070
1071    async fn insert_running_workflow(
1072        engine: &crate::Engine,
1073    ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1074        let workflow_id = WorkflowId::new_v4();
1075        let run_id = aion_core::RunId::new_v4();
1076        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1077        recorder
1078            .record_workflow_started(
1079                Utc::now(),
1080                crate::durability::WorkflowStartRecord {
1081                    workflow_type: "checkout".to_owned(),
1082                    input: payload()?,
1083                    run_id: run_id.clone(),
1084                    parent_run_id: None,
1085                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1086                },
1087            )
1088            .await?;
1089        let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1090            workflow_id: workflow_id.clone(),
1091            run_id: run_id.clone(),
1092            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1093            workflow_type: "checkout".to_owned(),
1094            namespace: String::from("default"),
1095            loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1096            cached_status: WorkflowStatus::Running,
1097            residency: crate::registry::HandleResidency::Resident,
1098            recorder,
1099            completion: crate::registry::CompletionNotifier::new(),
1100        });
1101        engine
1102            .registry()
1103            .insert((workflow_id.clone(), run_id.clone()), handle)?;
1104        Ok((workflow_id, run_id))
1105    }
1106
1107    #[tokio::test]
1108    async fn query_timeout_installs_the_concrete_query_seam()
1109    -> Result<(), Box<dyn std::error::Error>> {
1110        let engine = EngineBuilder::new()
1111            .store(InMemoryStore::default())
1112            .in_memory_visibility()
1113            .query_timeout(Duration::from_millis(250))
1114            .build()
1115            .await?;
1116        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1117
1118        // The concrete seam reached the query mailbox engine, which answers
1119        // an unregistered name with a typed UnknownQuery — the deferred seam
1120        // would have failed with its "not configured" runtime error instead.
1121        let result = engine.query(&workflow_id, &run_id, "state").await;
1122
1123        assert!(matches!(
1124            result,
1125            Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1126        ));
1127        engine.shutdown()?;
1128        Ok(())
1129    }
1130
1131    #[tokio::test]
1132    async fn without_query_timeout_the_query_seam_stays_deferred()
1133    -> Result<(), Box<dyn std::error::Error>> {
1134        let engine = EngineBuilder::new()
1135            .store(InMemoryStore::default())
1136            .in_memory_visibility()
1137            .build()
1138            .await?;
1139        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1140
1141        let result = engine.query(&workflow_id, &run_id, "state").await;
1142
1143        assert!(matches!(
1144            result,
1145            Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1146        ));
1147        engine.shutdown()?;
1148        Ok(())
1149    }
1150
1151    #[test]
1152    fn owned_shards_are_only_set_by_caller() {
1153        // The default builder configures NO shard restriction: `build()` never
1154        // touches the store's scoping hook, so single-node boot owns ALL shards
1155        // and is byte-identical to today.
1156        assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1157        assert_eq!(
1158            EngineBuilder::new()
1159                .owned_shards([2, 0, 2, 1])
1160                .configured_owned_shards(),
1161            Some([2, 0, 2, 1].as_slice())
1162        );
1163    }
1164
1165    #[test]
1166    fn scheduler_threads_are_only_set_by_caller() {
1167        assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1168        assert_eq!(
1169            EngineBuilder::new()
1170                .scheduler_threads(4)
1171                .scheduler_thread_count(),
1172            Some(4)
1173        );
1174    }
1175
1176    #[test]
1177    fn visibility_reconciliation_interval_is_only_set_by_caller() {
1178        let interval = Duration::from_millis(250);
1179
1180        assert_eq!(
1181            EngineBuilder::new().configured_visibility_reconciliation_interval(),
1182            None
1183        );
1184        assert_eq!(
1185            EngineBuilder::new()
1186                .visibility_reconciliation_interval(interval)
1187                .configured_visibility_reconciliation_interval(),
1188            Some(interval)
1189        );
1190    }
1191
1192    #[tokio::test]
1193    async fn duplicate_host_nif_mfa_returns_typed_error() {
1194        let mfa = Mfa::new("host", "zero", 0);
1195        let error = EngineBuilder::new()
1196            .store(InMemoryStore::default())
1197            .in_memory_visibility()
1198            .register_nifs([
1199                NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1200                NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1201            ])
1202            .build()
1203            .await
1204            .err();
1205
1206        assert!(matches!(
1207            error,
1208            Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1209        ));
1210    }
1211
1212    #[tokio::test]
1213    async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1214    -> Result<(), EngineError> {
1215        let store = Arc::new(InMemoryStore::default());
1216        let engine = EngineBuilder::new()
1217            .store_arc(store.clone())
1218            .in_memory_visibility()
1219            .build()
1220            .await?;
1221
1222        assert!(engine.registry().list()?.is_empty());
1223        assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1224        assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1225
1226        let coordinator_id = schedule_coordinator_workflow_id();
1227        let active = store.list_active().await?;
1228        assert_eq!(active, vec![coordinator_id.clone()]);
1229        let history = store.read_history(&coordinator_id).await?;
1230        let [started] = history.as_slice() else {
1231            return Err(EngineError::Load {
1232                reason: format!(
1233                    "expected exactly one coordinator event, found {}",
1234                    history.len()
1235                ),
1236            });
1237        };
1238        match started {
1239            Event::WorkflowStarted {
1240                workflow_type,
1241                input,
1242                run_id,
1243                parent_run_id,
1244                ..
1245            } => {
1246                assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1247                assert_eq!(
1248                    input,
1249                    &Payload::from_json(&json!({})).map_err(|error| {
1250                        EngineError::Load {
1251                            reason: format!("failed to build expected payload: {error}"),
1252                        }
1253                    })?
1254                );
1255                assert_eq!(run_id, &schedule_coordinator_run_id());
1256                assert!(parent_run_id.is_none());
1257            }
1258            other => {
1259                return Err(EngineError::Load {
1260                    reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1261                });
1262            }
1263        }
1264
1265        engine.shutdown()?;
1266        let rebuilt = EngineBuilder::new()
1267            .store_arc(store.clone())
1268            .in_memory_visibility()
1269            .build()
1270            .await?;
1271        let rebuilt_history = store.read_history(&coordinator_id).await?;
1272        assert_eq!(rebuilt_history.len(), 1);
1273        rebuilt.shutdown()?;
1274
1275        Ok(())
1276    }
1277
1278    #[tokio::test]
1279    async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1280        let package = fixture_package()?;
1281        let version = package.content_hash().clone();
1282        let deployed_entry_module = package.deployed_entry_module();
1283
1284        let engine = EngineBuilder::new()
1285            .store(InMemoryStore::default())
1286            .in_memory_visibility()
1287            .load_workflows(package)
1288            .build()
1289            .await?;
1290
1291        let loaded = engine
1292            .workflow_catalog()
1293            .get("counter", &version)?
1294            .ok_or("loaded package record missing")?;
1295        assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1296        assert!(
1297            engine
1298                .runtime()
1299                .has_registered_module(&deployed_entry_module)
1300        );
1301        Ok(())
1302    }
1303
1304    #[tokio::test]
1305    async fn startup_reconciliation_backfills_completed_visibility()
1306    -> Result<(), Box<dyn std::error::Error>> {
1307        let store = Arc::new(InMemoryStore::default());
1308        let completed_id = WorkflowId::new_v4();
1309
1310        store
1311            .append(
1312                WriteToken::recorder(),
1313                &completed_id,
1314                &[
1315                    started(&completed_id, "billing")?,
1316                    completed(&completed_id)?,
1317                ],
1318                0,
1319            )
1320            .await?;
1321
1322        let engine = EngineBuilder::new()
1323            .store_arc(store.clone())
1324            .visibility_store_arc(store.clone())
1325            .build()
1326            .await?;
1327
1328        let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1329        let completed_summary = summaries
1330            .iter()
1331            .find(|summary| summary.workflow_id == completed_id)
1332            .ok_or("completed workflow missing from visibility")?;
1333
1334        assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1335        assert!(completed_summary.close_time.is_some());
1336        engine.shutdown()?;
1337        Ok(())
1338    }
1339
1340    #[tokio::test]
1341    async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1342    -> Result<(), Box<dyn std::error::Error>> {
1343        let store = Arc::new(InMemoryStore::default());
1344        let engine = EngineBuilder::new()
1345            .store_arc(store.clone())
1346            .visibility_store_arc(store.clone())
1347            .visibility_reconciliation_interval(Duration::from_millis(25))
1348            .build()
1349            .await?;
1350        let workflow_id = WorkflowId::new_v4();
1351
1352        store
1353            .append(
1354                WriteToken::recorder(),
1355                &workflow_id,
1356                &[started(&workflow_id, "checkout")?],
1357                0,
1358            )
1359            .await?;
1360
1361        tokio::time::timeout(Duration::from_secs(2), async {
1362            loop {
1363                let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1364                if summaries.iter().any(|summary| {
1365                    summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1366                }) {
1367                    return Ok::<(), aion_store::StoreError>(());
1368                }
1369                tokio::time::sleep(Duration::from_millis(10)).await;
1370            }
1371        })
1372        .await??;
1373
1374        engine.shutdown()?;
1375        Ok(())
1376    }
1377
1378    #[tokio::test]
1379    async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1380        let package = fixture_package()?;
1381        let version = package.content_hash().clone();
1382        let path = write_fixture_package(&package)?;
1383
1384        let engine = EngineBuilder::new()
1385            .store(InMemoryStore::default())
1386            .in_memory_visibility()
1387            .load_workflows(path.as_path())
1388            .build()
1389            .await?;
1390        std::fs::remove_file(path)?;
1391
1392        assert!(
1393            engine
1394                .workflow_catalog()
1395                .get("counter", &version)?
1396                .is_some()
1397        );
1398        Ok(())
1399    }
1400}