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::Package;
9use aion_store::visibility::VisibilityStore;
10use aion_store::{EventStore, InMemoryStore};
11
12use crate::{
13    ActivityServing, EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig,
14    SupervisionTree,
15    activity::bridge::ActivityDispatcher,
16    durability::ActiveWorkflowRecoverySeam,
17    runtime::{NifEntry, NifRegistration},
18    signal::SignalResumeHandoff,
19};
20
21use super::api::{Engine, EngineComponents};
22use super::builder_assembly::{
23    ChildBridgeAssembly, assemble_startup_catalog, assemble_workloop_runtime, claim_owned_shards,
24    install_engine_nif_seams, install_workflow_nif_bridges, maybe_spawn_visibility_reconciliation,
25    reserved_search_attribute_schema,
26};
27use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
28use super::seams::{
29    SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
30};
31use super::startup::{StartupRecoveryContext, resolve_startup_recovery};
32
33/// Source for a workflow package collected before `build()` performs fallible
34/// loading and runtime registration.
35#[derive(Clone, Debug)]
36pub enum WorkflowPackageSource {
37    /// Load a package from this `.aion` archive path during `build()`.
38    Path(PathBuf),
39    /// Use an already-loaded package value.
40    Package(Box<Package>),
41}
42
43impl From<Package> for WorkflowPackageSource {
44    fn from(package: Package) -> Self {
45        Self::Package(Box::new(package))
46    }
47}
48
49impl From<PathBuf> for WorkflowPackageSource {
50    fn from(path: PathBuf) -> Self {
51        Self::Path(path)
52    }
53}
54
55impl From<&std::path::Path> for WorkflowPackageSource {
56    fn from(path: &std::path::Path) -> Self {
57        Self::Path(path.to_path_buf())
58    }
59}
60
61impl From<&str> for WorkflowPackageSource {
62    fn from(path: &str) -> Self {
63        Self::Path(PathBuf::from(path))
64    }
65}
66
67impl From<String> for WorkflowPackageSource {
68    fn from(path: String) -> Self {
69        Self::Path(PathBuf::from(path))
70    }
71}
72
73/// Tracks which optional engine seams the caller explicitly overrode, so
74/// `build()` can detect mutually-exclusive configuration (e.g. an event
75/// publisher set both directly and via event streaming). Grouped so the builder
76/// keeps its boolean configuration flags few and named.
77#[derive(Default)]
78struct SeamOverrides {
79    /// The caller installed an explicit event-publisher seam.
80    event_publisher: bool,
81    /// The caller installed an explicit query-service seam.
82    query_service: bool,
83}
84
85/// Builder for the embedded, transport-agnostic workflow engine.
86pub struct EngineBuilder {
87    store: Option<Arc<dyn EventStore>>,
88    visibility_store: Option<Arc<dyn VisibilityStore>>,
89    scheduler_threads: Option<usize>,
90    scheduler_jit_threshold: Option<u32>,
91    signal_delivery: SignalDeliveryConfig,
92    completion_retry: crate::runtime::CompletionRetryConfig,
93    outbox_enabled: bool,
94    bootstrap_schedule_coordinator: bool,
95    owned_shards: Option<Vec<usize>>,
96    workflow_sources: Vec<WorkflowPackageSource>,
97    host_nifs: Vec<NifEntry>,
98    recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
99    delegated: DelegatedSeams,
100    signal_router_factory: Option<SignalRouterFactory>,
101    activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
102    activity_serving: ActivityServing,
103    active_registry: Option<Arc<Registry>>,
104    visibility_reconciliation_interval: Option<Duration>,
105    search_attribute_schema: SearchAttributeSchema,
106    event_streaming_capacity: Option<NonZeroUsize>,
107    query_timeout: Option<Duration>,
108    seam_overrides: SeamOverrides,
109    defer_startup_recovery: bool,
110    workloop: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
111}
112
113impl Default for EngineBuilder {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl EngineBuilder {
120    /// Create a builder with no store, no scheduler-thread override, no loaded
121    /// workflows, and no host NIFs.
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            store: None,
126            visibility_store: None,
127            scheduler_threads: None,
128            scheduler_jit_threshold: None,
129            signal_delivery: SignalDeliveryConfig::default(),
130            completion_retry: crate::runtime::CompletionRetryConfig::default(),
131            outbox_enabled: false,
132            // The only field that defaults true: single-node engines seed the
133            // schedule coordinator. A multi-node deployment disables it on nodes
134            // that do not own the coordinator's shard (see the builder method).
135            bootstrap_schedule_coordinator: true,
136            // No shard restriction by default: the store owns ALL shards, which
137            // is byte-identical to single-node behaviour. `build()` only ever
138            // touches owned-shard scoping when a deployment sets this.
139            owned_shards: None,
140            workflow_sources: Vec::new(),
141            host_nifs: Vec::new(),
142            recovery: None,
143            delegated: DelegatedSeams::default(),
144            signal_router_factory: None,
145            activity_dispatcher: None,
146            activity_serving: ActivityServing::QueueRouted,
147            active_registry: None,
148            visibility_reconciliation_interval: None,
149            search_attribute_schema: SearchAttributeSchema::new(),
150            event_streaming_capacity: None,
151            query_timeout: None,
152            seam_overrides: SeamOverrides::default(),
153            defer_startup_recovery: false,
154            workloop: None,
155        }
156    }
157
158    /// Configure the workloop cadence machinery (workloop brief Leg 2): the
159    /// durable workloop store plus the operator-declared sweep interval,
160    /// which bounds dead-man detection latency. BOTH are required together —
161    /// there is no default sweep interval, and an engine built without this
162    /// call refuses every workloop verb.
163    #[must_use]
164    pub fn with_workloop_service(
165        mut self,
166        store: Arc<dyn aion_store::workloop::WorkloopStore>,
167        sweep_interval: Duration,
168    ) -> Self {
169        self.workloop = Some((store, sweep_interval));
170        self
171    }
172
173    /// Record the caller-supplied workflow query reply timeout.
174    ///
175    /// Setting a timeout installs the concrete query-dispatch seam during
176    /// `build()` (unless [`Self::query_service`] overrides it) and enables
177    /// the in-engine `dispatch_query` NIF. There is no default: without this
178    /// call the query seam stays deferred and `Engine::query` fails typed
179    /// with its "not configured" error.
180    #[must_use]
181    pub const fn query_timeout(mut self, timeout: Duration) -> Self {
182        self.query_timeout = Some(timeout);
183        self
184    }
185
186    /// Inspect the configured workflow query reply timeout.
187    #[must_use]
188    pub const fn configured_query_timeout(&self) -> Option<Duration> {
189        self.query_timeout
190    }
191
192    /// Opt in to live event streaming with a caller-provided broadcast capacity.
193    ///
194    /// `build()` wraps the configured store in a
195    /// [`PublishingEventStore`](crate::publish::PublishingEventStore) before any
196    /// recorder, recovery, or NIF bridge captures the store — so every
197    /// successful append publishes — and installs the matching
198    /// [`BroadcastEventPublisher`](crate::publish::BroadcastEventPublisher) as
199    /// the event-publisher seam behind [`Engine::subscribe`]. Without this call
200    /// the deferred publisher remains installed and subscriptions are empty.
201    #[must_use]
202    pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
203        self.event_streaming_capacity = Some(capacity);
204        self
205    }
206
207    /// Supply the search attribute schema validating every recorded attribute.
208    ///
209    /// The default schema is empty, which rejects all search attributes: a
210    /// deployment must declare each attribute name and type before workflows
211    /// can record values for it.
212    #[must_use]
213    pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
214        self.search_attribute_schema = schema;
215        self
216    }
217
218    /// Supply the event store used by the engine.
219    #[must_use]
220    pub fn store<S>(mut self, store: S) -> Self
221    where
222        S: EventStore,
223    {
224        self.store = Some(Arc::new(store));
225        self
226    }
227
228    /// Supply an already type-erased event store.
229    #[must_use]
230    pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
231        self.store = Some(store);
232        self
233    }
234
235    /// Supply the visibility store used by the engine for workflow projections.
236    #[must_use]
237    pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
238    where
239        S: VisibilityStore,
240    {
241        self.visibility_store = Some(Arc::new(visibility_store));
242        self
243    }
244
245    /// Supply an already type-erased visibility store.
246    #[must_use]
247    pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
248        self.visibility_store = Some(visibility_store);
249        self
250    }
251
252    /// Explicitly opt in to an ephemeral in-memory visibility store.
253    ///
254    /// This is intended for tests and local scenarios that do not need durable
255    /// visibility projections. Visibility data stored this way does not survive
256    /// process restarts.
257    #[must_use]
258    pub fn in_memory_visibility(mut self) -> Self {
259        self.visibility_store = Some(Arc::new(InMemoryStore::default()));
260        self
261    }
262
263    /// Record the caller-supplied scheduler thread count.
264    ///
265    /// If this setter is never called, `None` is passed through to beamr.
266    #[must_use]
267    pub const fn scheduler_threads(mut self, threads: usize) -> Self {
268        self.scheduler_threads = Some(threads);
269        self
270    }
271
272    /// Record the caller-supplied JIT compilation threshold.
273    ///
274    /// If this setter is never called, `None` is passed through to beamr, which
275    /// applies its own default. Read [`crate::RuntimeConfig::jit_threshold`]
276    /// before choosing a value — a large one defers compilation past any
277    /// realistic workload but is **not** a JIT off-switch.
278    #[must_use]
279    pub const fn scheduler_jit_threshold(mut self, threshold: u32) -> Self {
280        self.scheduler_jit_threshold = Some(threshold);
281        self
282    }
283
284    /// Record the caller-supplied periodic visibility reconciliation interval.
285    ///
286    /// If this setter is never called, no periodic background reconciliation task is spawned.
287    #[must_use]
288    pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
289        self.visibility_reconciliation_interval = Some(interval);
290        self
291    }
292
293    /// Record the caller-supplied signal delivery readiness and retry policy.
294    #[must_use]
295    pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
296        self.signal_delivery = signal_delivery;
297        self
298    }
299
300    /// Record the caller-supplied durable completion-retry backoff ladder.
301    ///
302    /// Separate from [`Self::signal_delivery`] because the completion retry is
303    /// unbounded by attempts and sleeps between durable store round-trips; see
304    /// [`crate::runtime::CompletionRetryConfig`] for why sharing one ladder
305    /// between the two was a defect.
306    #[must_use]
307    pub const fn completion_retry(
308        mut self,
309        completion_retry: crate::runtime::CompletionRetryConfig,
310    ) -> Self {
311        self.completion_retry = completion_retry;
312        self
313    }
314
315    /// Record whether the durable-outbox fan-out dispatch path is enabled.
316    #[must_use]
317    pub fn outbox_enabled(mut self, enabled: bool) -> Self {
318        self.outbox_enabled = enabled;
319        self
320    }
321
322    /// Control whether `build()` seeds the schedule-coordinator history.
323    ///
324    /// Default `true` (single-node). Under multi-shard active-active the
325    /// coordinator stream is owned by exactly ONE shard; a deployment sets this
326    /// `false` on every node that does NOT own that shard, so only the owner
327    /// seeds (and serves) it — a non-owner would otherwise try to write the
328    /// coordinator stream and race or fence the real owner.
329    #[must_use]
330    pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
331        self.bootstrap_schedule_coordinator = enabled;
332        self
333    }
334
335    /// Restrict this engine's store to the distribution shards this node owns.
336    ///
337    /// Under multi-shard active-active a node serves only a SUBSET of the
338    /// cluster's shards. `build()` calls
339    /// [`ReadableEventStore::set_owned_shards`](aion_store::ReadableEventStore::set_owned_shards)
340    /// with this set BEFORE startup recovery, so the node recovers and
341    /// enumerates only the workflows / timers / outbox rows that live on its
342    /// shards. The set is deduplicated and ordered by the store.
343    ///
344    /// Not calling this leaves the store owning ALL shards — the single-node
345    /// default, which is byte-identical to today's behaviour (`build()` never
346    /// touches the scoping hook). The single-shard in-memory backend ignores the
347    /// call regardless, since it owns everything unconditionally; haematite
348    /// honours it.
349    #[must_use]
350    pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
351        self.owned_shards = Some(shards.into_iter().collect());
352        self
353    }
354
355    /// Inspect the configured owned-shard set (`None` = own all shards).
356    #[must_use]
357    pub fn configured_owned_shards(&self) -> Option<&[usize]> {
358        self.owned_shards.as_deref()
359    }
360
361    /// Add one workflow package source to load during `build()`.
362    #[must_use]
363    pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
364        self.workflow_sources.push(source.into());
365        self
366    }
367
368    /// Add many workflow package sources to load during `build()`.
369    #[must_use]
370    pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
371    where
372        I: IntoIterator<Item = S>,
373        S: Into<WorkflowPackageSource>,
374    {
375        self.workflow_sources
376            .extend(sources.into_iter().map(Into::into));
377        self
378    }
379
380    /// Collect host-supplied NIF entries to install before workflow modules load.
381    #[must_use]
382    pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
383        self.host_nifs.extend(entries);
384        self
385    }
386
387    /// Override the AD recovery seam used while repopulating active workflows.
388    #[must_use]
389    pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
390        self.recovery = Some(recovery);
391        self
392    }
393
394    /// Use the production AD recovery seam created after runtime/package loading.
395    #[must_use]
396    pub fn production_recovery_seam(mut self) -> Self {
397        self.recovery = None;
398        self
399    }
400
401    /// Defer the four startup-recovery steps out of `build()` (#266).
402    ///
403    /// Recovery replay re-dispatches every in-flight activity through the
404    /// configured activity dispatcher the moment it runs. A host that
405    /// decorates that dispatcher with collaborators it can only install once
406    /// the engine exists (the server's declared-body source) must therefore
407    /// build first, install its seams, and only then run recovery — by
408    /// calling [`Engine::run_startup_recovery`] exactly once. Without this
409    /// call, `build()` runs recovery itself, byte-identically to before this
410    /// seam existed, and `run_startup_recovery` refuses typed.
411    #[must_use]
412    pub fn defer_startup_recovery(mut self) -> Self {
413        self.defer_startup_recovery = true;
414        self
415    }
416
417    /// Override the AT signal-routing seam.
418    #[must_use]
419    pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
420        self.signal_router_factory = None;
421        self.delegated = DelegatedSeams::new(
422            signal_router,
423            self.delegated.query_service_arc(),
424            self.delegated.event_publisher_arc(),
425        );
426        self
427    }
428
429    /// Override the AT signal-routing seam after the runtime is assembled.
430    #[must_use]
431    pub fn signal_router_factory<F>(mut self, factory: F) -> Self
432    where
433        F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
434            + Send
435            + Sync
436            + 'static,
437    {
438        self.signal_router_factory = Some(Arc::new(factory));
439        self
440    }
441
442    /// Override the AT query-dispatch seam.
443    ///
444    /// An explicit override wins over the concrete service that
445    /// [`Self::query_timeout`] would otherwise install during `build()`.
446    #[must_use]
447    pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
448        self.seam_overrides.query_service = true;
449        self.delegated = DelegatedSeams::new(
450            self.delegated.signal_router_arc(),
451            query_service,
452            self.delegated.event_publisher_arc(),
453        );
454        self
455    }
456
457    /// Override the AD/AT live event-publisher seam.
458    ///
459    /// Mutually exclusive with [`Self::event_streaming`], which installs the
460    /// broadcast publisher itself; configuring both fails `build()`.
461    #[must_use]
462    pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
463        self.seam_overrides.event_publisher = true;
464        self.delegated = DelegatedSeams::new(
465            self.delegated.signal_router_arc(),
466            self.delegated.query_service_arc(),
467            event_publisher,
468        );
469        self
470    }
471
472    /// Supply the activity dispatcher that backs activity dispatch NIFs.
473    ///
474    /// When set, the dispatcher is installed in the global bridge before
475    /// workflow modules are loaded. Without a dispatcher, `dispatch_activity`
476    /// returns an error to workflow code instead of crashing the process.
477    #[must_use]
478    pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
479        self.activity_dispatcher = Some(dispatcher);
480        self
481    }
482
483    /// Declare that the configured in-process [`ActivityDispatcher`] fulfils
484    /// every activity this engine dispatches — the embedded posture.
485    ///
486    /// This gates ONLY the structural queue-service admission check: with
487    /// in-process serving declared, a package whose contract retains
488    /// unscoped (legacy-manifest) activities is admitted at start, because
489    /// no queue exists to be unserved — dispatch runs on the configured
490    /// dispatcher immediately, and a missing dispatcher fails loudly at
491    /// dispatch. The `.v4` identity floor is unconditional either way. The
492    /// server never declares this: its dispatcher routes to task queues, so
493    /// it keeps the fail-closed queue-routed default.
494    #[must_use]
495    pub const fn in_process_activity_serving(mut self) -> Self {
496        self.activity_serving = ActivityServing::InProcess;
497        self
498    }
499
500    /// Supply the active workflow registry used by the built engine.
501    ///
502    /// Server-owned dispatchers that run behind raw NIFs use this to correlate a
503    /// calling BEAM pid to the same workflow handle the engine registers.
504    #[must_use]
505    pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
506        self.active_registry = Some(registry);
507        self
508    }
509
510    /// Inspect the configured scheduler thread count.
511    #[must_use]
512    pub const fn scheduler_thread_count(&self) -> Option<usize> {
513        self.scheduler_threads
514    }
515
516    /// Inspect the configured JIT compilation threshold.
517    #[must_use]
518    pub const fn configured_jit_threshold(&self) -> Option<u32> {
519        self.scheduler_jit_threshold
520    }
521
522    /// Inspect the configured periodic visibility reconciliation interval.
523    #[must_use]
524    pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
525        self.visibility_reconciliation_interval
526    }
527
528    /// Assemble the runtime configuration from the builder-supplied scheduler,
529    /// signal delivery, completion-retry, and outbox knobs.
530    fn runtime_config(&self) -> RuntimeConfig {
531        RuntimeConfig::new(self.scheduler_threads)
532            .with_jit_threshold(self.scheduler_jit_threshold)
533            .with_signal_delivery(self.signal_delivery)
534            .with_completion_retry(self.completion_retry)
535            .with_outbox_enabled(self.outbox_enabled)
536    }
537
538    /// Start the runtime and install the engine plus host NIFs.
539    fn start_runtime_with_nifs(
540        runtime_config: RuntimeConfig,
541        host_nifs: Vec<NifEntry>,
542    ) -> Result<Arc<RuntimeHandle>, EngineError> {
543        let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
544        let mut nifs = NifRegistration::new();
545        nifs.add_engine_nifs().add_host_nifs(host_nifs);
546        runtime.install_nifs(nifs)?;
547        Ok(runtime)
548    }
549
550    /// Construct the live engine.
551    ///
552    /// # Errors
553    ///
554    /// Returns typed [`EngineError`] variants for missing store, runtime startup,
555    /// NIF registration, package loading, store reads, registry/supervision lock
556    /// poison, or deferred AD recovery failures for active histories.
557    pub async fn build(self) -> Result<Engine, EngineError> {
558        let runtime_config = self.runtime_config();
559        let (store, streaming_publisher) = wrap_event_streaming(
560            self.store.ok_or(EngineError::MissingStore)?,
561            self.event_streaming_capacity,
562            self.seam_overrides.event_publisher,
563        )?;
564        let visibility_store = self
565            .visibility_store
566            .ok_or(EngineError::MissingVisibilityStore)?;
567        claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
568
569        let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
570
571        // Persisted runtime deploys must be resident before startup recovery
572        // below resolves any run's recorded pinned version — this is the
573        // restart half of the deploy durability promise.
574        let catalog = assemble_startup_catalog(
575            runtime.as_ref(),
576            store.as_ref(),
577            self.workflow_sources,
578            self.activity_serving,
579        )
580        .await?;
581
582        let registry = self
583            .active_registry
584            .unwrap_or_else(|| Arc::new(Registry::default()));
585        let nif_state = Arc::clone(runtime.nif_state());
586        let query_mailbox_engine = install_engine_nif_seams(
587            &nif_state,
588            &registry,
589            &store,
590            &runtime,
591            self.activity_dispatcher,
592            self.query_timeout,
593        );
594        let supervision = Arc::new(SupervisionTree::new());
595        let search_attribute_schema =
596            reserved_search_attribute_schema(self.search_attribute_schema)?;
597        let signal_handoff = Arc::new(SignalResumeHandoff::new());
598
599        let delegated = assemble_delegated_seams(SeamAssembly {
600            configured: self.delegated,
601            signal_router_factory: self.signal_router_factory,
602            runtime: Arc::clone(&runtime),
603            signal_handoff: Arc::clone(&signal_handoff),
604            streaming_publisher,
605            query_mailbox_engine,
606            query_timeout: self.query_timeout,
607            query_service_overridden: self.seam_overrides.query_service,
608        });
609
610        // Startup recovery re-spawns active workflow processes, and those
611        // processes begin replaying on scheduler threads immediately. Replay
612        // re-executes workflow code through the engine NIFs, so every NIF
613        // bridge (signal, child) must be installed before the first recovered
614        // process can run, or an early replayed spawn_child/receive_signal
615        // call fails with a missing-bridge error.
616        let bridge_assembly = ChildBridgeAssembly {
617            nif_state: &nif_state,
618            store: &store,
619            visibility_store: &visibility_store,
620            runtime: &runtime,
621            catalog: &catalog,
622            registry: &registry,
623            supervision: &supervision,
624            signal_handoff: &signal_handoff,
625            search_attribute_schema: &search_attribute_schema,
626            watch_backoff: self.signal_delivery,
627        };
628        install_workflow_nif_bridges(&bridge_assembly, &delegated)?;
629
630        // A deferred build (#266) skips the recovery steps and stows what the
631        // host owes [`Engine::run_startup_recovery`]; see
632        // [`resolve_startup_recovery`].
633        let deferred_startup_recovery = resolve_startup_recovery(
634            self.defer_startup_recovery,
635            &nif_state,
636            StartupRecoveryContext {
637                store: Arc::clone(&store),
638                visibility_store: Arc::clone(&visibility_store),
639                runtime: Arc::clone(&runtime),
640                catalog: Arc::clone(&catalog),
641                registry: Arc::clone(&registry),
642                supervision: Arc::clone(&supervision),
643                recovery: self.recovery,
644                search_attribute_schema: Arc::clone(&search_attribute_schema),
645                bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
646            },
647        )
648        .await?;
649
650        let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
651            self.visibility_reconciliation_interval,
652            &store,
653            &visibility_store,
654        );
655
656        let workloop = Self::assemble_workloops(self.workloop, &bridge_assembly, &store).await?;
657
658        let deferred = deferred_startup_recovery.is_some();
659        let engine = Engine::new(EngineComponents {
660            store,
661            visibility_store,
662            runtime,
663            catalog,
664            registry,
665            supervision,
666            delegated,
667            signal_handoff,
668            search_attribute_schema,
669            visibility_reconciliation_task,
670            deferred_startup_recovery,
671            workloop,
672        });
673        if !deferred {
674            Self::recover_engine_schedules(&engine).await?;
675        }
676        Ok(engine)
677    }
678
679    /// Reconcile orphaned workloop registrations, THEN stand the cadence
680    /// machinery up. The order is the point of this function existing.
681    ///
682    /// # 🔴 BOOT IS WHERE AN ORPHANED REGISTRATION BECOMES PROVABLE, AND THE
683    /// RECONCILIATION MUST HAPPEN BEFORE THE SWEEP TASK EXISTS TO SEE ONE.
684    ///
685    /// `Engine::start_workloop` writes the sweep-set row before the workflow
686    /// exists, and must — the reverse order loses a race with the loop's own
687    /// first close. A crash in that window strands a row for a workflow with no
688    /// history. In a running process that row is indistinguishable from an
689    /// in-flight start; here it is not, because no start can be in flight
690    /// across a process boundary. Withdrawing those rows AHEAD of
691    /// `assemble_workloop_runtime` — which spawns the sweep task — is what
692    /// makes "no fire is ever aimed at a loop that never started" a property of
693    /// this sequence rather than a hope about which task ticks first. Binding
694    /// the two into one call is what keeps it that way under later edits.
695    async fn assemble_workloops(
696        configured: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
697        bridge_assembly: &ChildBridgeAssembly<'_>,
698        store: &Arc<dyn EventStore>,
699    ) -> Result<Option<super::api_workloop::WorkloopEngineRuntime>, EngineError> {
700        if let Some((workloop_store, _)) = configured.as_ref() {
701            crate::workloop::service::withdraw_unstarted_registrations(workloop_store, store)
702                .await
703                .map_err(EngineError::from)?;
704        }
705        assemble_workloop_runtime(configured, bridge_assembly)
706    }
707
708    /// The non-deferred build's schedule recovery tail, in one place so
709    /// `build()` stays within the lint's line budget without losing a step.
710    async fn recover_engine_schedules(engine: &Engine) -> Result<(), EngineError> {
711        engine.catchup_schedule_coordinator().await?;
712        engine.recover_schedules_on_startup(Utc::now()).await?;
713        Ok(())
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    // 🔴 #125. This was a hand-rolled twelve-line gate announcing its skip with
720    // `println!` — the exact pattern task #74 replaced, because the harness
721    // captures `println!` on a PASSING test and a gated skip always passes, so
722    // the announcement was invisible in precisely the case it existed for.
723    //
724    // #74's cure landed in `tests/test_support/gleam.rs` and is pinned across
725    // four byte-identical copies. This file is `src/`, so a sweep over `tests/`
726    // could never see it — and the correct copy was already sitting in THIS
727    // crate's own `tests/` directory. A fix's search space is part of the fix.
728    //
729    // Reusing the canonical file by path rather than copying it, per the
730    // precedent in `aion-package/src/structure/mod.rs`: a third copy would look
731    // right in isolation and drift silently. It is DECLARED in `engine/mod.rs`
732    // because `#[path]` resolves against the declaring module's directory and
733    // `src/engine/builder/` does not exist.
734    use super::super::gleam_test_support;
735
736    use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
737
738    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
739    use aion_package::{
740        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
741        ManifestVersion, Package, PackageBuilder,
742    };
743    use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
744    use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
745    use chrono::Utc;
746    use futures::StreamExt;
747    use serde_json::json;
748
749    use crate::engine::api_schedule::{
750        schedule_coordinator_run_id, schedule_coordinator_workflow_id,
751        schedule_coordinator_workflow_type,
752    };
753    use crate::runtime::{Determinism, Mfa, NifEntry};
754
755    use super::EngineBuilder;
756    use crate::EngineError;
757
758    fn payload() -> Result<Payload, aion_core::PayloadError> {
759        Payload::from_json(&json!({ "input": true }))
760    }
761
762    fn started(
763        workflow_id: &WorkflowId,
764        workflow_type: &str,
765    ) -> Result<Event, aion_core::PayloadError> {
766        Ok(Event::WorkflowStarted {
767            envelope: EventEnvelope {
768                seq: 1,
769                recorded_at: Utc::now(),
770                workflow_id: workflow_id.clone(),
771            },
772            workflow_type: workflow_type.to_owned(),
773            input: payload()?,
774            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
775            parent_run_id: None,
776            parent_workflow_id: None,
777            package_version: aion_core::PackageVersion::new("a".repeat(64)),
778        })
779    }
780
781    fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
782        Ok(Event::WorkflowCompleted {
783            envelope: EventEnvelope {
784                seq: 2,
785                recorded_at: Utc::now(),
786                workflow_id: workflow_id.clone(),
787            },
788            result: payload()?,
789        })
790    }
791
792    fn package_manifest() -> Manifest {
793        Manifest {
794            entry_module: "counter".to_owned(),
795            entry_function: "version".to_owned(),
796            input_schema: json!({ "type": "object" }),
797            output_schema: json!({ "type": "integer" }),
798            timeout: Some(Duration::from_secs(30)),
799            activities: vec![DeclaredActivity {
800                activity_type: "activity/test".to_owned(),
801            }],
802            version: ManifestVersion::new("test"),
803            format_version: CURRENT_FORMAT_VERSION,
804            additional_workflows: Vec::new(),
805        }
806    }
807
808    fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
809        let temp_dir =
810            std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
811        std::fs::create_dir(&temp_dir)?;
812        let source_path = temp_dir.join("counter.erl");
813        let beam_path = temp_dir.join("counter.beam");
814        std::fs::write(
815            &source_path,
816            "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
817        )?;
818        let status = Command::new("erlc")
819            .arg("-o")
820            .arg(&temp_dir)
821            .arg(&source_path)
822            .status()?;
823        if !status.success() {
824            let cleanup_result = std::fs::remove_dir_all(&temp_dir);
825            drop(cleanup_result);
826            return Err(format!("erlc failed with status {status}").into());
827        }
828        let bytes = std::fs::read(beam_path)?;
829        std::fs::remove_dir_all(temp_dir)?;
830        Ok(bytes)
831    }
832
833    fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
834        let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
835        let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
836        Ok(Package::load_from_bytes(
837            archive,
838            ExtractionLimits::unbounded(),
839        )?)
840    }
841
842    fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
843        let path =
844            std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
845        PackageBuilder::new(package.manifest().clone(), package.beams().clone())
846            .write_to_path(&path)?;
847        Ok(path)
848    }
849
850    #[tokio::test]
851    async fn build_without_store_returns_missing_store() {
852        let error = EngineBuilder::new().build().await.err();
853
854        assert!(matches!(error, Some(EngineError::MissingStore)));
855    }
856
857    #[tokio::test]
858    async fn build_without_visibility_store_returns_missing_visibility_store() {
859        let error = EngineBuilder::new()
860            .store(InMemoryStore::default())
861            .build()
862            .await
863            .err();
864
865        assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
866    }
867
868    #[tokio::test]
869    async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
870    {
871        let engine = EngineBuilder::new()
872            .store(InMemoryStore::default())
873            .in_memory_visibility()
874            .build()
875            .await?;
876
877        engine.shutdown()?;
878        Ok(())
879    }
880
881    /// 🔴 M-1. Releasing an engine WITHOUT shutting it down closes the epoch.
882    ///
883    /// `RuntimeHandle::shutdown` covers the explicit path. This covers the other
884    /// one, and it is not hypothetical: a completion retry appends terminal
885    /// events, so an engine dropped on an error path with a retry still armed
886    /// would keep writing to histories nobody believes this process still owns.
887    ///
888    /// The refcount backstop cannot serve here, which is the whole reason this
889    /// `Drop` exists. `EngineTaskRuntime::drop` fires only when the last strong
890    /// reference goes, and an in-flight attempt holds one for the entire attempt
891    /// — so at the instant the hazard is real, the backstop is pinned shut. This
892    /// gate is set by a `Drop` that runs regardless of who else holds a handle.
893    ///
894    /// The first assertion is the control: an engine that never opened the epoch
895    /// would satisfy the second trivially.
896    ///
897    /// 🔴 THE ENGINE IS BUILT WITH A RECONCILIATION INTERVAL, AND THAT IS NOT
898    /// INCIDENTAL. Its first form used a bare fixture with no interval, so no
899    /// reconciliation task was ever spawned — and a fixture that never starts
900    /// the thing under test cannot see whether releasing the engine stops it.
901    /// It could not: `Drop` did not abort `visibility_reconciliation_task`, and
902    /// dropping a `JoinHandle` DETACHES rather than cancels, so a released
903    /// engine left an unbounded loop holding both stores and calling
904    /// `reconcile_visibility`, which WRITES. Two properties are pinned here
905    /// because one `Drop` owns both: the epoch closes, and the writer stops.
906    ///
907    /// The writer half is measured by counting the loop's own ticks —
908    /// `reconcile_visibility` reads `list_workflows` unconditionally on every
909    /// pass — through a wrapper that delegates everything to a real store, so
910    /// what is observed is the production loop and not a stand-in for it. The
911    /// wait for two ticks before the drop is the positive control: it fails the
912    /// test if the loop was never running, which is the exact way the first
913    /// form of this test was vacuous.
914    #[tokio::test]
915    async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
916    -> Result<(), EngineError> {
917        let visibility = Arc::new(TickCountingVisibilityStore::default());
918        let engine = EngineBuilder::new()
919            .store(InMemoryStore::default())
920            .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
921            .visibility_reconciliation_interval(Duration::from_millis(10))
922            .build()
923            .await?;
924        // Taken before the drop and held across it: reading through the engine
925        // afterwards is impossible, and reading a fresh handle would observe a
926        // different runtime.
927        let tasks = engine.runtime().engine_tasks();
928        assert!(
929            tasks.is_epoch_open(),
930            "control: a live engine's epoch must be open, or the assertion below is trivially \
931             satisfied"
932        );
933
934        // Positive control for the writer half: the loop must be observably
935        // running BEFORE the drop, or "it stopped" is a statement about a task
936        // that never started. Bounded so a stalled loop fails rather than hangs.
937        let ticking = tokio::time::timeout(Duration::from_secs(10), async {
938            while visibility.ticks() < 2 {
939                tokio::time::sleep(Duration::from_millis(5)).await;
940            }
941        })
942        .await;
943        assert!(
944            ticking.is_ok(),
945            "control: the periodic reconciliation loop must be running before the engine is \
946             dropped, or this test measures nothing; it reached {} ticks",
947            visibility.ticks()
948        );
949
950        drop(engine);
951
952        assert!(
953            !tasks.is_epoch_open(),
954            "an engine released without an explicit shutdown must still close the epoch, or a \
955             completion retry can append a terminal for a run this process no longer owns"
956        );
957
958        // `abort` is asynchronous: it schedules cancellation, so give the
959        // runtime a window several intervals wide to deliver it before reading
960        // the baseline. Anything after that baseline is a task still looping.
961        tokio::time::sleep(Duration::from_millis(200)).await;
962        let after_abort = visibility.ticks();
963        tokio::time::sleep(Duration::from_millis(200)).await;
964        assert_eq!(
965            visibility.ticks(),
966            after_abort,
967            "an engine released without an explicit shutdown must also stop the visibility \
968             reconciliation loop; it ticked again over twenty intervals after the drop, which \
969             means a detached task is still WRITING to a visibility store this process no longer \
970             owns"
971        );
972        Ok(())
973    }
974
975    /// How far ahead of "now" the shared wheel-timer fixture arms its deadline.
976    ///
977    /// 🔴 THIS IS A RACE MARGIN, NOT A TASTE. The release test has to reach
978    /// `drop(engine)` while the timer is still pending; if the deadline passes
979    /// first, the timer fires LEGITIMATELY and the test's own message blames
980    /// `Drop` for a surviving task that never survived — a red that reassigns
981    /// blame, which is worse than no test. The margin buys a window that the
982    /// two atomic reads and one drop between arming and release cannot
983    /// plausibly exhaust, and the release test asserts it was still inside that
984    /// window rather than assuming it.
985    const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
986
987    /// How far past the deadline both wheel-timer tests observe before reading
988    /// history — the fire path is asynchronous, so the deadline arriving is not
989    /// the same as the append having landed.
990    const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
991
992    /// A resident run with one live wheel timer, armed through the production
993    /// path, shared by the release test and its positive control. Returns the
994    /// workflow and the deadline it armed, because the release test has to
995    /// check it is still inside the arming window before it can attribute
996    /// anything to the release.
997    ///
998    /// 🔴 ONE FIXTURE, DELIBERATELY. The control's whole job is to show that
999    /// THIS arrangement's timer reaches the store; a second copy that drifted —
1000    /// a different residency, a different timer name, a different deadline —
1001    /// would control nothing while still reading as a control.
1002    ///
1003    /// The deadline has to arrive INSIDE the test. The hazard a released engine
1004    /// leaves behind is a parked task that later wakes and records, so a
1005    /// deadline beyond the test's own lifetime makes "nothing fired" true for a
1006    /// reason that has nothing to do with the release. That is what
1007    /// [`WHEEL_TIMER_ARMING_MARGIN`] trades against: near enough to observe,
1008    /// far enough not to race the drop.
1009    async fn arm_resident_run_with_wheel_timer(
1010        engine: &crate::Engine,
1011        store: &Arc<InMemoryStore>,
1012    ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
1013        use aion_store::EventStore;
1014
1015        use crate::durability::{Recorder, WorkflowStartRecord};
1016        use crate::registry::{
1017            CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
1018        };
1019
1020        // The wheel is armed only for a workflow the registry resolves as
1021        // `Resident`, and `TimerService::schedule` is the production path that
1022        // arms it.
1023        let workflow_id = WorkflowId::new_v4();
1024        let run_id = aion_core::RunId::new_v4();
1025        let timer_id = aion_core::TimerId::named("sleep")?;
1026        let mut recorder = Recorder::new(
1027            workflow_id.clone(),
1028            Arc::clone(store) as Arc<dyn EventStore>,
1029        );
1030        recorder
1031            .record_workflow_started(
1032                Utc::now(),
1033                WorkflowStartRecord {
1034                    workflow_type: "checkout".to_owned(),
1035                    input: payload()?,
1036                    run_id: run_id.clone(),
1037                    parent_run_id: None,
1038                    parent_workflow_id: None,
1039                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1040                },
1041            )
1042            .await?;
1043        recorder
1044            .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
1045            .await?;
1046        engine.registry().insert(
1047            (workflow_id.clone(), run_id.clone()),
1048            WorkflowHandle::new(WorkflowHandleParts {
1049                workflow_id: workflow_id.clone(),
1050                run_id,
1051                pid: 1,
1052                workflow_type: "checkout".to_owned(),
1053                namespace: String::from("default"),
1054                loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
1055                cached_status: WorkflowStatus::Running,
1056                residency: HandleResidency::Resident,
1057                recorder,
1058                completion: CompletionNotifier::new(),
1059            }),
1060        )?;
1061
1062        let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
1063        crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1064            .map_err(|error| format!("the engine installed no timer service: {error}"))?
1065            .schedule(workflow_id.clone(), timer_id, fire_at, 1)
1066            .await?;
1067        Ok((workflow_id, fire_at))
1068    }
1069
1070    /// 🔴 M-1's THIRD WRITER, AND
1071    /// `dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch`
1072    /// IS BLIND TO IT.
1073    ///
1074    /// `Drop for Engine` disarms the live timer wheel as well as closing the
1075    /// engine-task epoch and aborting the reconciliation loop. That sibling's
1076    /// fixture never arms a wheel timer, so deleting `shutdown_timer_wheel` from
1077    /// `Drop` leaves every one of its assertions green — a fixture that never
1078    /// starts the thing under test cannot see whether releasing the engine stops
1079    /// it, which is the same defect that test's own comment records having had.
1080    ///
1081    /// The wheel is a DURABLE writer: a fired wheel timer records `TimerFired`
1082    /// through the run's recorder. An engine released with one still armed
1083    /// therefore appends to a history this process no longer owns — the #119
1084    /// failover race reached through a different door, because
1085    /// `RuntimeHandle::shutdown` stops the beamr scheduler while the armed tasks
1086    /// live on the tokio runtime and are not reached by it. On the release path
1087    /// there is no shutdown at all, so `Drop` is the only thing that can.
1088    ///
1089    /// The count is read through an `Arc<EngineNifState>` held ACROSS the drop,
1090    /// so the release is the only variable between the two readings. The
1091    /// pre-drop assertion is the control and it is not decoration: it is the
1092    /// exact vacuity this test exists to close.
1093    #[tokio::test]
1094    async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1095    -> Result<(), Box<dyn std::error::Error>> {
1096        use aion_store::EventStore;
1097
1098        let store = Arc::new(InMemoryStore::default());
1099        let engine = EngineBuilder::new()
1100            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1101            .visibility_store(InMemoryStore::default())
1102            .build()
1103            .await?;
1104        // Held across the drop; see `EngineNifState::armed_wheel_timers`.
1105        let nif_state = Arc::clone(engine.runtime().nif_state());
1106        let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1107        assert_eq!(
1108            nif_state.armed_wheel_timers(),
1109            1,
1110            "control: the fixture must have actually armed a wheel timer, or the assertions below \
1111             are satisfied by an engine that never had one — the exact way the sibling test above \
1112             is blind to this half of `Drop`"
1113        );
1114
1115        // 🔴 FIXTURE GUARD, NOT A CLAIM ABOUT `Drop`. Everything below reads a
1116        // timer that has NOT yet fired; if the deadline has already passed, a
1117        // `TimerFired` in history is the timer doing its job, and the decisive
1118        // assertion would report it as a task that survived the release. That is
1119        // a red which reassigns blame — the failure mode this guard exists to
1120        // make impossible to mistake. Both readings are taken before the drop,
1121        // so neither can be explained by it.
1122        let before_drop = Utc::now();
1123        assert!(
1124            before_drop < fire_at,
1125            "fixture: the arming window closed before the release was reached ({before_drop} is \
1126             not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1127             and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1128             this as `Drop` leaking a task."
1129        );
1130        let armed = store.read_history(&workflow_id).await?;
1131        assert!(
1132            !armed
1133                .iter()
1134                .any(|event| matches!(event, Event::TimerFired { .. })),
1135            "fixture: the timer already fired before the engine was released, so this run cannot \
1136             say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1137        );
1138
1139        drop(engine);
1140
1141        assert_eq!(
1142            nif_state.armed_wheel_timers(),
1143            0,
1144            "an engine released without an explicit shutdown must empty its live timer wheel"
1145        );
1146
1147        // 🔴 AND THE TASK ITSELF IS GONE, WHICH THE COUNT ABOVE CANNOT SEE.
1148        // `shutdown_timer_wheel` both removes the entry and aborts the handle;
1149        // the count observes only the removal, so deleting the abort leaves it
1150        // green while an armed task lives on. This waits past the deadline and
1151        // reads the durable record, which is where a surviving task would show
1152        // up — and the sibling test
1153        // `a_live_wheel_timer_fires_when_the_engine_is_not_released` is its
1154        // positive control: it proves this same fixture's timer DOES reach the
1155        // store when nothing disarms it, so an empty reading here is a
1156        // cancellation rather than a timer that was never going to arrive.
1157        tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1158        let history = store.read_history(&workflow_id).await?;
1159        assert!(
1160            !history
1161                .iter()
1162                .any(|event| matches!(event, Event::TimerFired { .. })),
1163            "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1164             merely forget them: a surviving task records `TimerFired` for a run this process no \
1165             longer owns, which is a second writer for one workflow: {history:#?}"
1166        );
1167        Ok(())
1168    }
1169
1170    /// 🔴 AND A DRAIN IS NOT A GATE. The test above proves the wheel is EMPTIED
1171    /// by the release; this one proves it stays empty.
1172    ///
1173    /// `Drop for Engine` deliberately leaves the beamr scheduler running and the
1174    /// engine seams installed, so a workflow process still runnable can reach
1175    /// `sleep` a moment AFTER the drain and arm a fresh task — whose body is
1176    /// `fire_wheel_timer`, a durable `TimerFired` append against a run a
1177    /// successor engine may already own. That is the same second-writer breach
1178    /// the drain exists to prevent, reached one instant later, and until
1179    /// 2026-08-07 `arm_timer` had no check of any kind: it spawned
1180    /// unconditionally.
1181    ///
1182    /// The scheduling call is the PRODUCTION path, not a direct poke at the
1183    /// bridge, and it is made through an `Arc<EngineNifState>` held across the
1184    /// drop — the same instrument the sibling above uses, and available for the
1185    /// same reason (the drop does not clear the seams).
1186    ///
1187    /// Two controls, because refusal has two boring explanations. The first
1188    /// schedule succeeds BEFORE the drop, so the fixture demonstrably reaches
1189    /// the arming path at all; and the count is asserted `1` then `0` around the
1190    /// release, so the post-drop `Err` cannot be a fixture that was never able
1191    /// to arm anything.
1192    #[tokio::test]
1193    async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1194    -> Result<(), Box<dyn std::error::Error>> {
1195        use aion_store::EventStore;
1196
1197        let store = Arc::new(InMemoryStore::default());
1198        let engine = EngineBuilder::new()
1199            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1200            .visibility_store(InMemoryStore::default())
1201            .build()
1202            .await?;
1203        let nif_state = Arc::clone(engine.runtime().nif_state());
1204        // CONTROL 1: this fixture can arm. The call below is the same
1205        // `TimerService::schedule` the post-drop attempt makes, and it succeeds.
1206        let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1207        assert_eq!(
1208            nif_state.armed_wheel_timers(),
1209            1,
1210            "control: the fixture must have armed a wheel timer through the production path, or \
1211             the refusal below is satisfied by a fixture that could never arm one"
1212        );
1213
1214        drop(engine);
1215
1216        // CONTROL 2: the release emptied the wheel, so what follows is measured
1217        // against a torn-down wheel rather than a busy one.
1218        assert_eq!(nif_state.armed_wheel_timers(), 0);
1219
1220        let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1221            .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1222            .schedule(
1223                workflow_id,
1224                aion_core::TimerId::named("sleep-after-release")?,
1225                Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1226                1,
1227            )
1228            .await;
1229        let Err(error) = refused else {
1230            return Err(
1231                "arming a wheel timer through a released engine must be REFUSED: the task \
1232                        it spawns appends a durable `TimerFired` for a run this process no longer \
1233                        owns, which is a second writer for one workflow"
1234                    .into(),
1235            );
1236        };
1237        assert!(
1238            error.to_string().contains("torn down"),
1239            "the refusal must say WHY, so an operator reading it is not sent looking for a \
1240             missing workflow or a bad timer id: {error}"
1241        );
1242        assert_eq!(
1243            nif_state.armed_wheel_timers(),
1244            0,
1245            "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1246             would be a durable writer with no owner to cancel it"
1247        );
1248        Ok(())
1249    }
1250
1251    /// The positive control for the assertion above: this fixture's wheel timer
1252    /// really does reach the durable record when nothing disarms it.
1253    ///
1254    /// Without this, "no `TimerFired` after the drop" is equally well explained
1255    /// by a fixture whose timer could never fire at all — a registry entry the
1256    /// fire path rejects, a seam that was never installed, a deadline that never
1257    /// arrives. An absence is only evidence when its presence has been shown
1258    /// reachable by the same means.
1259    #[tokio::test]
1260    async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1261    -> Result<(), Box<dyn std::error::Error>> {
1262        use aion_store::EventStore;
1263
1264        let store = Arc::new(InMemoryStore::default());
1265        let engine = EngineBuilder::new()
1266            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1267            .visibility_store(InMemoryStore::default())
1268            .build()
1269            .await?;
1270        let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1271
1272        tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1273
1274        let history = store.read_history(&workflow_id).await?;
1275        assert!(
1276            history
1277                .iter()
1278                .any(|event| matches!(event, Event::TimerFired { .. })),
1279            "the fixture's wheel timer must reach the store when the engine is still held, or \
1280             the sibling test's absence proves nothing: {history:#?}"
1281        );
1282        drop(engine);
1283        Ok(())
1284    }
1285
1286    /// A visibility store that counts the periodic reconciliation loop's passes.
1287    ///
1288    /// Delegates every method to a real [`InMemoryStore`] so the loop under
1289    /// observation does the same work it does in production; the counter is the
1290    /// only addition. `list_workflows` is the tick site because
1291    /// `reconcile_visibility` calls it unconditionally at the top of every pass,
1292    /// so the count cannot be lowered by the store happening to be consistent.
1293    #[derive(Default)]
1294    struct TickCountingVisibilityStore {
1295        inner: InMemoryStore,
1296        list_calls: std::sync::atomic::AtomicUsize,
1297    }
1298
1299    impl TickCountingVisibilityStore {
1300        fn ticks(&self) -> usize {
1301            self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1302        }
1303    }
1304
1305    #[async_trait::async_trait]
1306    impl VisibilityStore for TickCountingVisibilityStore {
1307        async fn record_visibility(
1308            &self,
1309            record: aion_store::visibility::VisibilityRecord,
1310        ) -> Result<(), aion_store::StoreError> {
1311            self.inner.record_visibility(record).await
1312        }
1313
1314        async fn list_workflows(
1315            &self,
1316            filter: ListWorkflowsFilter,
1317        ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1318            self.list_calls
1319                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1320            self.inner.list_workflows(filter).await
1321        }
1322
1323        async fn count_workflows(
1324            &self,
1325            filter: ListWorkflowsFilter,
1326        ) -> Result<u64, aion_store::StoreError> {
1327            self.inner.count_workflows(filter).await
1328        }
1329    }
1330
1331    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1332        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1333    }
1334
1335    #[tokio::test]
1336    async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1337    -> Result<(), Box<dyn std::error::Error>> {
1338        let engine = EngineBuilder::new()
1339            .store(InMemoryStore::default())
1340            .in_memory_visibility()
1341            .event_streaming(capacity(8)?)
1342            .build()
1343            .await?;
1344        let workflow_id = WorkflowId::new_v4();
1345        let mut subscription = engine.subscribe(crate::EventFilter {
1346            workflow_id: Some(workflow_id.clone()),
1347            run: None,
1348            family: None,
1349        });
1350
1351        // The production append path: a Recorder over the engine's store,
1352        // which `event_streaming` wrapped before any recorder existed.
1353        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1354        recorder
1355            .record_workflow_started(
1356                Utc::now(),
1357                crate::durability::WorkflowStartRecord {
1358                    workflow_type: "checkout".to_owned(),
1359                    input: payload()?,
1360                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1361                    parent_run_id: None,
1362                    parent_workflow_id: None,
1363                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1364                },
1365            )
1366            .await?;
1367
1368        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1369            .await?
1370            .ok_or("subscription ended without delivering the appended event")?;
1371        let event = item?;
1372        assert_eq!(event.workflow_id(), &workflow_id);
1373        assert_eq!(event.seq(), 1);
1374        assert!(matches!(event, Event::WorkflowStarted { .. }));
1375        engine.shutdown()?;
1376        Ok(())
1377    }
1378
1379    #[tokio::test]
1380    async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1381    -> Result<(), Box<dyn std::error::Error>> {
1382        let engine = EngineBuilder::new()
1383            .store(InMemoryStore::default())
1384            .in_memory_visibility()
1385            .build()
1386            .await?;
1387
1388        let mut subscription = engine.subscribe(crate::EventFilter::default());
1389        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1390
1391        assert!(item.is_none(), "deferred publisher streams must be empty");
1392        engine.shutdown()?;
1393        Ok(())
1394    }
1395
1396    #[tokio::test]
1397    async fn event_streaming_conflicts_with_explicit_event_publisher()
1398    -> Result<(), Box<dyn std::error::Error>> {
1399        let error = EngineBuilder::new()
1400            .store(InMemoryStore::default())
1401            .in_memory_visibility()
1402            .event_publisher(Arc::new(crate::DeferredEventPublisher))
1403            .event_streaming(capacity(8)?)
1404            .build()
1405            .await
1406            .err();
1407
1408        assert!(matches!(
1409            error,
1410            Some(EngineError::ConflictingEventPublisher)
1411        ));
1412        Ok(())
1413    }
1414
1415    #[test]
1416    fn query_timeout_is_only_set_by_caller() {
1417        assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1418        assert_eq!(
1419            EngineBuilder::new()
1420                .query_timeout(Duration::from_secs(3))
1421                .configured_query_timeout(),
1422            Some(Duration::from_secs(3))
1423        );
1424    }
1425
1426    async fn insert_running_workflow(
1427        engine: &crate::Engine,
1428    ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1429        let workflow_id = WorkflowId::new_v4();
1430        let run_id = aion_core::RunId::new_v4();
1431        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1432        recorder
1433            .record_workflow_started(
1434                Utc::now(),
1435                crate::durability::WorkflowStartRecord {
1436                    workflow_type: "checkout".to_owned(),
1437                    input: payload()?,
1438                    run_id: run_id.clone(),
1439                    parent_run_id: None,
1440                    parent_workflow_id: None,
1441                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1442                },
1443            )
1444            .await?;
1445        let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1446            workflow_id: workflow_id.clone(),
1447            run_id: run_id.clone(),
1448            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1449            workflow_type: "checkout".to_owned(),
1450            namespace: String::from("default"),
1451            loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1452            cached_status: WorkflowStatus::Running,
1453            residency: crate::registry::HandleResidency::Resident,
1454            recorder,
1455            completion: crate::registry::CompletionNotifier::new(),
1456        });
1457        engine
1458            .registry()
1459            .insert((workflow_id.clone(), run_id.clone()), handle)?;
1460        Ok((workflow_id, run_id))
1461    }
1462
1463    #[tokio::test]
1464    async fn query_timeout_installs_the_concrete_query_seam()
1465    -> Result<(), Box<dyn std::error::Error>> {
1466        let engine = EngineBuilder::new()
1467            .store(InMemoryStore::default())
1468            .in_memory_visibility()
1469            .query_timeout(Duration::from_millis(250))
1470            .build()
1471            .await?;
1472        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1473
1474        // The concrete seam reached the query mailbox engine, which answers
1475        // an unregistered name with a typed UnknownQuery — the deferred seam
1476        // would have failed with its "not configured" runtime error instead.
1477        let result = engine
1478            .query(
1479                &workflow_id,
1480                &run_id,
1481                "state",
1482                aion_core::Payload::json_null(),
1483            )
1484            .await;
1485
1486        assert!(matches!(
1487            result,
1488            Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1489        ));
1490        engine.shutdown()?;
1491        Ok(())
1492    }
1493
1494    #[tokio::test]
1495    async fn without_query_timeout_the_query_seam_stays_deferred()
1496    -> Result<(), Box<dyn std::error::Error>> {
1497        let engine = EngineBuilder::new()
1498            .store(InMemoryStore::default())
1499            .in_memory_visibility()
1500            .build()
1501            .await?;
1502        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1503
1504        let result = engine
1505            .query(
1506                &workflow_id,
1507                &run_id,
1508                "state",
1509                aion_core::Payload::json_null(),
1510            )
1511            .await;
1512
1513        assert!(matches!(
1514            result,
1515            Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1516        ));
1517        engine.shutdown()?;
1518        Ok(())
1519    }
1520
1521    #[test]
1522    fn owned_shards_are_only_set_by_caller() {
1523        // The default builder configures NO shard restriction: `build()` never
1524        // touches the store's scoping hook, so single-node boot owns ALL shards
1525        // and is byte-identical to today.
1526        assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1527        assert_eq!(
1528            EngineBuilder::new()
1529                .owned_shards([2, 0, 2, 1])
1530                .configured_owned_shards(),
1531            Some([2, 0, 2, 1].as_slice())
1532        );
1533    }
1534
1535    #[test]
1536    fn scheduler_threads_are_only_set_by_caller() {
1537        assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1538        assert_eq!(
1539            EngineBuilder::new()
1540                .scheduler_threads(4)
1541                .scheduler_thread_count(),
1542            Some(4)
1543        );
1544    }
1545
1546    /// The completion-retry ladder the caller set is the ladder the runtime is
1547    /// started with.
1548    ///
1549    /// Asserting on `runtime_config()` rather than on a builder accessor is the
1550    /// whole point: an accessor test would pass with the
1551    /// `.with_completion_retry(self.completion_retry)` line deleted, because the
1552    /// field would still hold what the setter put there while the runtime
1553    /// silently ran on the inherited default. This is the only place that link
1554    /// is held.
1555    ///
1556    /// The first assertion is the test's own control. It fixes that the chosen
1557    /// ladder differs from the default, so the equality below cannot be
1558    /// satisfied by a runtime configuration that ignored the builder entirely.
1559    #[test]
1560    fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1561    -> Result<(), Box<dyn std::error::Error>> {
1562        let chosen = crate::runtime::CompletionRetryConfig::try_new(
1563            Duration::from_millis(250),
1564            Duration::from_secs(7),
1565        )?;
1566        assert_ne!(
1567            chosen,
1568            crate::runtime::CompletionRetryConfig::default(),
1569            "a ladder equal to the default could not distinguish wiring from inheritance"
1570        );
1571
1572        assert_eq!(
1573            EngineBuilder::new()
1574                .completion_retry(chosen)
1575                .runtime_config()
1576                .completion_retry,
1577            chosen,
1578            "the runtime must be started with the operator's ladder, not the inherited default"
1579        );
1580        Ok(())
1581    }
1582
1583    /// The JIT threshold the caller set is the threshold the runtime is started
1584    /// with.
1585    ///
1586    /// Asserting on `runtime_config()` rather than on
1587    /// `configured_jit_threshold()` is the whole point, for the reason given on
1588    /// the completion-retry test above: an accessor test stays green with the
1589    /// `.with_jit_threshold(self.scheduler_jit_threshold)` line deleted, because
1590    /// the builder field would still hold what the setter put there while the
1591    /// scheduler silently ran on beamr's inherited default. This is the only
1592    /// place that link is held.
1593    ///
1594    /// That distinction is not academic here. The threshold's whole purpose is
1595    /// to *move the moment of compilation* so a compile-correlated fault can be
1596    /// shown to track it. A silently-ignored threshold would not fail loudly —
1597    /// it would produce a run that looks like a measurement and measured the
1598    /// default, which is the shape of every instrument that reports a
1599    /// comfortable number without touching what it claims to touch.
1600    ///
1601    /// The first assertion is the control: it fixes that the unset builder does
1602    /// **not** name a threshold, so the equality below cannot be satisfied by a
1603    /// configuration that ignored the builder and happened to match.
1604    #[test]
1605    fn the_jit_threshold_reaches_the_runtime_configuration() {
1606        assert_eq!(
1607            EngineBuilder::new().runtime_config().jit_threshold,
1608            None,
1609            "an unset builder must pass None through, so beamr applies its own default rather \
1610             than a value aion invented"
1611        );
1612
1613        assert_eq!(
1614            EngineBuilder::new()
1615                .scheduler_jit_threshold(100)
1616                .runtime_config()
1617                .jit_threshold,
1618            Some(100),
1619            "the scheduler must be started with the operator's threshold, not beamr's default — \
1620             otherwise a proportionality run reports on the default while claiming to vary it"
1621        );
1622    }
1623
1624    #[test]
1625    fn visibility_reconciliation_interval_is_only_set_by_caller() {
1626        let interval = Duration::from_millis(250);
1627
1628        assert_eq!(
1629            EngineBuilder::new().configured_visibility_reconciliation_interval(),
1630            None
1631        );
1632        assert_eq!(
1633            EngineBuilder::new()
1634                .visibility_reconciliation_interval(interval)
1635                .configured_visibility_reconciliation_interval(),
1636            Some(interval)
1637        );
1638    }
1639
1640    #[tokio::test]
1641    async fn duplicate_host_nif_mfa_returns_typed_error() {
1642        let mfa = Mfa::new("host", "zero", 0);
1643        let error = EngineBuilder::new()
1644            .store(InMemoryStore::default())
1645            .in_memory_visibility()
1646            .register_nifs([
1647                NifEntry::new(
1648                    mfa.clone(),
1649                    crate::runtime::nif::test_native_zero,
1650                    Determinism::Pure,
1651                ),
1652                NifEntry::dirty(
1653                    mfa,
1654                    crate::runtime::nif::test_native_zero,
1655                    Determinism::Pure,
1656                ),
1657            ])
1658            .build()
1659            .await
1660            .err();
1661
1662        assert!(matches!(
1663            error,
1664            Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1665        ));
1666    }
1667
1668    #[tokio::test]
1669    async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1670    -> Result<(), EngineError> {
1671        let store = Arc::new(InMemoryStore::default());
1672        let engine = EngineBuilder::new()
1673            .store_arc(store.clone())
1674            .in_memory_visibility()
1675            .build()
1676            .await?;
1677
1678        assert!(engine.registry().list()?.is_empty());
1679        assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1680        assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1681
1682        let coordinator_id = schedule_coordinator_workflow_id();
1683        let active = store.list_active().await?;
1684        assert_eq!(active, vec![coordinator_id.clone()]);
1685        let history = store.read_history(&coordinator_id).await?;
1686        let [started] = history.as_slice() else {
1687            return Err(EngineError::Load {
1688                reason: format!(
1689                    "expected exactly one coordinator event, found {}",
1690                    history.len()
1691                ),
1692            });
1693        };
1694        match started {
1695            Event::WorkflowStarted {
1696                workflow_type,
1697                input,
1698                run_id,
1699                parent_run_id,
1700                ..
1701            } => {
1702                assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1703                assert_eq!(
1704                    input,
1705                    &Payload::from_json(&json!({})).map_err(|error| {
1706                        EngineError::Load {
1707                            reason: format!("failed to build expected payload: {error}"),
1708                        }
1709                    })?
1710                );
1711                assert_eq!(run_id, &schedule_coordinator_run_id());
1712                assert!(parent_run_id.is_none());
1713            }
1714            other => {
1715                return Err(EngineError::Load {
1716                    reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1717                });
1718            }
1719        }
1720
1721        engine.shutdown()?;
1722        let rebuilt = EngineBuilder::new()
1723            .store_arc(store.clone())
1724            .in_memory_visibility()
1725            .build()
1726            .await?;
1727        let rebuilt_history = store.read_history(&coordinator_id).await?;
1728        assert_eq!(rebuilt_history.len(), 1);
1729        rebuilt.shutdown()?;
1730
1731        Ok(())
1732    }
1733
1734    #[tokio::test]
1735    async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1736        if gleam_test_support::skip_if_unavailable() {
1737            return Ok(());
1738        }
1739        let package = fixture_package()?;
1740        let version = package.content_hash().clone();
1741        let deployed_entry_module = package.deployed_entry_module();
1742
1743        let engine = EngineBuilder::new()
1744            .store(InMemoryStore::default())
1745            .in_memory_visibility()
1746            .load_workflows(package)
1747            .build()
1748            .await?;
1749
1750        let loaded = engine
1751            .workflow_catalog()
1752            .get("counter", &version)?
1753            .ok_or("loaded package record missing")?;
1754        assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1755        assert!(
1756            engine
1757                .runtime()
1758                .has_registered_module(&deployed_entry_module)
1759        );
1760        Ok(())
1761    }
1762
1763    #[tokio::test]
1764    async fn startup_reconciliation_backfills_completed_visibility()
1765    -> Result<(), Box<dyn std::error::Error>> {
1766        let store = Arc::new(InMemoryStore::default());
1767        let completed_id = WorkflowId::new_v4();
1768
1769        store
1770            .append(
1771                WriteToken::recorder(),
1772                &completed_id,
1773                &[
1774                    started(&completed_id, "billing")?,
1775                    completed(&completed_id)?,
1776                ],
1777                0,
1778            )
1779            .await?;
1780
1781        let engine = EngineBuilder::new()
1782            .store_arc(store.clone())
1783            .visibility_store_arc(store.clone())
1784            .build()
1785            .await?;
1786
1787        let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1788        let completed_summary = summaries
1789            .iter()
1790            .find(|summary| summary.workflow_id == completed_id)
1791            .ok_or("completed workflow missing from visibility")?;
1792
1793        assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1794        assert!(completed_summary.close_time.is_some());
1795        engine.shutdown()?;
1796        Ok(())
1797    }
1798
1799    #[tokio::test]
1800    async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1801    -> Result<(), Box<dyn std::error::Error>> {
1802        let store = Arc::new(InMemoryStore::default());
1803        let engine = EngineBuilder::new()
1804            .store_arc(store.clone())
1805            .visibility_store_arc(store.clone())
1806            .visibility_reconciliation_interval(Duration::from_millis(25))
1807            .build()
1808            .await?;
1809        let workflow_id = WorkflowId::new_v4();
1810
1811        store
1812            .append(
1813                WriteToken::recorder(),
1814                &workflow_id,
1815                &[started(&workflow_id, "checkout")?],
1816                0,
1817            )
1818            .await?;
1819
1820        tokio::time::timeout(Duration::from_secs(2), async {
1821            loop {
1822                let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1823                if summaries.iter().any(|summary| {
1824                    summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1825                }) {
1826                    return Ok::<(), aion_store::StoreError>(());
1827                }
1828                tokio::time::sleep(Duration::from_millis(10)).await;
1829            }
1830        })
1831        .await??;
1832
1833        engine.shutdown()?;
1834        Ok(())
1835    }
1836
1837    #[tokio::test]
1838    async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1839        if gleam_test_support::skip_if_unavailable() {
1840            return Ok(());
1841        }
1842        let package = fixture_package()?;
1843        let version = package.content_hash().clone();
1844        let path = write_fixture_package(&package)?;
1845
1846        let engine = EngineBuilder::new()
1847            .store(InMemoryStore::default())
1848            .in_memory_visibility()
1849            .load_workflows(path.as_path())
1850            .build()
1851            .await?;
1852        std::fs::remove_file(path)?;
1853
1854        assert!(
1855            engine
1856                .workflow_catalog()
1857                .get("counter", &version)?
1858                .is_some()
1859        );
1860        Ok(())
1861    }
1862}