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