Skip to main content

aion/engine/
delegated.rs

1//! signal/query/subscribe surface (AT/AD delegation)
2
3use aion_core::{Event, Payload, RunId, WorkflowId, current_lease_terminal, run_segment};
4use async_trait::async_trait;
5use futures::stream::{self, BoxStream};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9use crate::{Engine, EngineError, SignalRouterError, WorkflowHandle};
10
11use super::api::workflow_not_found;
12
13/// Live-event subscription filter consumed by the AD/AT publisher seam.
14///
15/// The `run` field is part of the cross-cluster contract even though the
16/// current core [`Event`] envelope does not yet carry run metadata; publisher
17/// implementations that know run residency out-of-band can apply it there.
18#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
19pub struct EventFilter {
20    /// Match events for this workflow execution id.
21    pub workflow_id: Option<WorkflowId>,
22    /// Match events for this run id when the publisher has run metadata.
23    pub run: Option<RunId>,
24    /// Match events belonging to this event family.
25    pub family: Option<EventFamily>,
26}
27
28impl EventFilter {
29    /// Returns whether an event satisfies the constraints visible on [`Event`].
30    ///
31    /// Run filtering is intentionally not decided here because [`Event`] does
32    /// not currently include a [`RunId`]; the publisher seam applies that field
33    /// with its own metadata when available.
34    #[must_use]
35    pub fn matches(&self, event: &Event) -> bool {
36        self.workflow_id
37            .as_ref()
38            .is_none_or(|workflow_id| event.workflow_id() == workflow_id)
39            && self
40                .family
41                .is_none_or(|family| family == event_family(event))
42    }
43}
44
45/// Coarse event families for live subscription filtering.
46#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
47pub enum EventFamily {
48    /// Workflow lifecycle events.
49    Workflow,
50    /// Activity scheduling and completion events.
51    Activity,
52    /// Timer events owned by AT.
53    Timer,
54    /// Signal delivery events owned by AT/AD.
55    Signal,
56    /// Child-workflow lifecycle events.
57    ChildWorkflow,
58    /// Schedule lifecycle and trigger events.
59    Schedule,
60}
61
62/// AT-005/AT-006 signal-routing seam.
63///
64/// Implementations record the signal through the workflow recorder and deliver
65/// it to the target workflow mailbox. The engine only resolves the live target
66/// handle and delegates to this trait.
67#[async_trait]
68pub trait SignalRouter: Send + Sync {
69    /// Route a signal to the already-resolved workflow target.
70    async fn route(
71        &self,
72        target: &WorkflowHandle,
73        name: String,
74        payload: Payload,
75    ) -> Result<(), EngineError>;
76}
77
78/// AT-007 query-dispatch seam.
79///
80/// Implementations dispatch a read-only query to workflow code and map their
81/// query errors into [`EngineError`]. The engine only resolves the live target
82/// handle and delegates to this trait.
83#[async_trait]
84pub trait QueryService: Send + Sync {
85    /// Dispatch a named query, with its caller-supplied arguments, to the
86    /// already-resolved workflow target.
87    ///
88    /// `arguments` is a type-erased [`Payload`] carrying the JSON document the
89    /// workflow's query handler decodes. `null` is the canonical "no
90    /// arguments" document. Arguments are read-only inputs: they are handed to
91    /// the handler and never recorded.
92    async fn query(
93        &self,
94        target: &WorkflowHandle,
95        name: String,
96        arguments: Payload,
97    ) -> Result<Payload, EngineError>;
98}
99
100/// A live subscription fell behind the publisher and skipped events.
101///
102/// Publishers yield this as a stream item — never a silent skip and never a
103/// silent stream end — and then continue with subsequent live events, so the
104/// consumer always learns exactly how many events it missed.
105#[derive(thiserror::Error, Clone, Copy, Debug, PartialEq, Eq)]
106#[error("event subscription lagged behind the live stream and skipped {skipped} events")]
107pub struct EventStreamLagged {
108    /// Number of events the subscriber missed.
109    pub skipped: u64,
110}
111
112/// AD/AT live event publisher seam.
113///
114/// Implementations own event publication and filtering. The engine exposes the
115/// in-process subscription surface without owning publication machinery.
116pub trait EventPublisher: Send + Sync {
117    /// Subscribe to a filtered stream of live workflow events.
118    ///
119    /// A subscriber that falls behind the publisher receives one
120    /// `Err(`[`EventStreamLagged`]`)` item carrying the skipped count and then
121    /// continues with subsequent events; lag never silently drops events and
122    /// never silently ends the stream.
123    fn subscribe(
124        &self,
125        filter: EventFilter,
126    ) -> BoxStream<'static, Result<Event, EventStreamLagged>>;
127}
128
129/// Object-safe delegated seams held by the engine for AT/AD integration.
130#[derive(Clone)]
131pub struct DelegatedSeams {
132    signal_router: Arc<dyn SignalRouter>,
133    query_service: Arc<dyn QueryService>,
134    event_publisher: Arc<dyn EventPublisher>,
135}
136
137impl DelegatedSeams {
138    /// Build a seam bundle from concrete AT/AD implementations.
139    #[must_use]
140    pub const fn new(
141        signal_router: Arc<dyn SignalRouter>,
142        query_service: Arc<dyn QueryService>,
143        event_publisher: Arc<dyn EventPublisher>,
144    ) -> Self {
145        Self {
146            signal_router,
147            query_service,
148            event_publisher,
149        }
150    }
151
152    /// Signal routing seam installed for AT-005/AT-006 delegation.
153    #[must_use]
154    pub fn signal_router(&self) -> &dyn SignalRouter {
155        self.signal_router.as_ref()
156    }
157
158    /// Query dispatch seam installed for AT-007 delegation.
159    #[must_use]
160    pub fn query_service(&self) -> &dyn QueryService {
161        self.query_service.as_ref()
162    }
163
164    /// Live event publisher seam installed for AD/AT delegation.
165    #[must_use]
166    pub fn event_publisher(&self) -> &dyn EventPublisher {
167        self.event_publisher.as_ref()
168    }
169
170    pub(crate) fn signal_router_arc(&self) -> Arc<dyn SignalRouter> {
171        Arc::clone(&self.signal_router)
172    }
173
174    pub(crate) fn query_service_arc(&self) -> Arc<dyn QueryService> {
175        Arc::clone(&self.query_service)
176    }
177
178    pub(crate) fn event_publisher_arc(&self) -> Arc<dyn EventPublisher> {
179        Arc::clone(&self.event_publisher)
180    }
181}
182
183impl Default for DelegatedSeams {
184    fn default() -> Self {
185        Self::new(
186            Arc::new(DeferredSignalRouter),
187            Arc::new(DeferredQueryService),
188            Arc::new(DeferredEventPublisher),
189        )
190    }
191}
192
193/// Deferred signal seam used until AT-005/AT-006 installs a concrete router.
194#[derive(Debug, Default)]
195pub struct DeferredSignalRouter;
196
197#[async_trait]
198impl SignalRouter for DeferredSignalRouter {
199    async fn route(
200        &self,
201        target: &WorkflowHandle,
202        name: String,
203        payload: Payload,
204    ) -> Result<(), EngineError> {
205        let _ = (target, name, payload);
206        Err(EngineError::Runtime {
207            reason: "signal routing seam is not configured".to_owned(),
208        })
209    }
210}
211
212/// Deferred query seam used until AT-007 installs a concrete service.
213#[derive(Debug, Default)]
214pub struct DeferredQueryService;
215
216#[async_trait]
217impl QueryService for DeferredQueryService {
218    async fn query(
219        &self,
220        target: &WorkflowHandle,
221        name: String,
222        arguments: Payload,
223    ) -> Result<Payload, EngineError> {
224        let _ = (target, name, arguments);
225        Err(EngineError::Runtime {
226            reason: "query service seam is not configured".to_owned(),
227        })
228    }
229}
230
231/// Deferred publisher seam used until AD/AT installs a concrete publisher.
232#[derive(Debug, Default)]
233pub struct DeferredEventPublisher;
234
235impl EventPublisher for DeferredEventPublisher {
236    fn subscribe(
237        &self,
238        filter: EventFilter,
239    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
240        let _ = filter;
241        Box::pin(stream::empty())
242    }
243}
244
245impl Engine {
246    /// Send a signal to a live workflow run through the AT routing seam.
247    ///
248    /// The signal is admitted against the signal type declared by the EXACT
249    /// package identity the target run is pinned to, BEFORE anything is
250    /// recorded and before the arrival can be consumed. A refused signal
251    /// therefore leaves the run's history byte-identical and the run parked on
252    /// exactly the wait it was parked on — the refusal reaches the caller, not
253    /// the run.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`EngineError::SignalRefused`] when the name is not declared by
258    /// the target's package or the payload does not satisfy the declared type
259    /// (nothing recorded, nothing consumed),
260    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is unknown,
261    /// [`SignalRouterError::Terminal`] when it is durably terminal, or other typed errors from
262    /// the configured signal seam.
263    pub async fn signal(
264        &self,
265        id: &WorkflowId,
266        run: &RunId,
267        name: impl Into<String>,
268        payload: Payload,
269    ) -> Result<(), EngineError> {
270        let name = name.into();
271        let handle = if let Some(handle) = self.registry().get(id, run)? {
272            // BOUNDARY ADMISSION (resident): the live handle carries the exact
273            // identity this run resolved at start, so it is the ruler here.
274            crate::signal::admission::admit_against_handle(
275                self.workflow_catalog(),
276                &handle,
277                &name,
278                &payload,
279            )?;
280            handle
281        } else {
282            let history = self.store().read_history(id).await?;
283            if run_has_terminal_history(&history, run) {
284                return Err(SignalRouterError::Terminal {
285                    workflow_id: id.clone(),
286                    run_id: run.clone(),
287                }
288                .into());
289            }
290            // BOUNDARY ADMISSION (non-resident): the recorded `WorkflowStarted`
291            // pins the same identity the live handle would have carried. This
292            // runs before BOTH remaining record paths below — the paused
293            // one-shot recorder and the birth-window handle's router — so no
294            // branch out of here can record a refused payload.
295            crate::signal::admission::admit_against_history(
296                self.workflow_catalog(),
297                id,
298                run,
299                &history,
300                &name,
301                &payload,
302            )?;
303            // Paused-but-not-resident (crashed while paused, #204): the run is
304            // durably non-terminal but was deliberately excluded from respawn, so
305            // no live handle exists and handle_after_birth_window would time out
306            // with WorkflowNotFound. Durably record SignalReceived through a
307            // one-shot recorder built from the validated read (record-before-
308            // deliver preserved); it takes effect on resume replay.
309            let segment = aion_core::run_segment(&history, run);
310            if matches!(
311                aion_core::status_from_events(segment),
312                aion_core::WorkflowStatus::Paused
313            ) {
314                let head = history.last().map(Event::seq).unwrap_or_default();
315                let mut recorder =
316                    crate::durability::Recorder::resume_at(id.clone(), self.store(), head)
317                        .with_visibility(run.clone(), self.visibility_store());
318                recorder
319                    .record_signal_received(chrono::Utc::now(), name, payload)
320                    .await?;
321                return Ok(());
322            }
323            self.handle_after_birth_window(id, run, &history)
324                .await?
325                .ok_or_else(|| workflow_not_found(id, run))?
326        };
327        self.delegated()
328            .signal_router()
329            .route(&handle, name, payload)
330            .await
331    }
332
333    /// Dispatch a read-only query, with its arguments, to a live workflow run
334    /// through the AT seam.
335    ///
336    /// `arguments` is the type-erased JSON document the workflow's registered
337    /// handler decodes; `null` is the canonical "no arguments" document.
338    /// Nothing on this path records an event — arguments are inputs to a
339    /// read-only handler, never history.
340    ///
341    /// # Errors
342    ///
343    /// Returns [`EngineError::Query`] with [`crate::query::QueryError::NotRunning`]
344    /// when the `(workflow, run)` pair is durably terminal,
345    /// [`EngineError::WorkflowNotFound`] when it is unknown,
346    /// [`crate::query::QueryError::InvalidArguments`] when `arguments` is not
347    /// a well-formed JSON document, and other typed errors from the configured
348    /// query seam.
349    pub async fn query(
350        &self,
351        id: &WorkflowId,
352        run: &RunId,
353        name: impl Into<String>,
354        arguments: Payload,
355    ) -> Result<Payload, EngineError> {
356        let handle = if let Some(handle) = self.registry().get(id, run)? {
357            handle
358        } else {
359            // Mirror Engine::signal's registry-miss handling: a completed
360            // workflow is NotRunning per the query contract, never NotFound.
361            let history = self.store().read_history(id).await?;
362            if run_has_terminal_history(&history, run) {
363                return Err(EngineError::Query(crate::query::QueryError::NotRunning(
364                    id.clone(),
365                )));
366            }
367            self.handle_after_birth_window(id, run, &history)
368                .await?
369                .ok_or_else(|| workflow_not_found(id, run))?
370        };
371        self.delegated()
372            .query_service()
373            .query(&handle, name.into(), arguments)
374            .await
375    }
376
377    /// Resolve a registry miss against the registration birth window.
378    ///
379    /// The start path records `WorkflowStarted` durably *before* it inserts
380    /// the registry handle, so an embedder acting on observed history (or on
381    /// a `start_workflow` racing on another task) can legitimately arrive
382    /// here after the record and before the insert. When the requested run
383    /// is durably started and non-terminal, the registry is re-polled within
384    /// the builder-supplied delivery policy budget; `None` after the budget
385    /// means the run truly has no live handle (its start failed or its
386    /// engine is gone) and the caller fails typed.
387    pub(crate) async fn handle_after_birth_window(
388        &self,
389        id: &WorkflowId,
390        run: &RunId,
391        history: &[Event],
392    ) -> Result<Option<WorkflowHandle>, EngineError> {
393        let started = history
394            .iter()
395            .any(|event| matches!(event, Event::WorkflowStarted { run_id, .. } if run_id == run));
396        if !started {
397            return Ok(None);
398        }
399        wait_for_registered_handle(self.registry(), id, run, self.runtime().signal_delivery()).await
400    }
401
402    /// Subscribe to the live event stream through the AD/AT publisher seam.
403    ///
404    /// A subscriber that falls behind receives one `Err(`[`EventStreamLagged`]`)`
405    /// item with the skipped count and then continues with subsequent events.
406    #[must_use]
407    pub fn subscribe(
408        &self,
409        filter: EventFilter,
410    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
411        self.delegated().event_publisher().subscribe(filter)
412    }
413}
414
415pub(crate) fn run_has_terminal_history(history: &[Event], run: &RunId) -> bool {
416    // Reset-aware: a run is terminal only if its current lease ended in a
417    // terminal event. A WorkflowReopened after a terminal reopens the run, so a
418    // reopened run is not treated as terminal (it can receive signals and
419    // complete again).
420    current_lease_terminal(run_segment(history, run)).is_some()
421}
422
423/// Poll the registry for `(id, run)` until the handle appears or the
424/// builder-supplied delivery budget is spent.
425///
426/// The budget is the policy's full persistence — `ready_timeout ×
427/// max_enqueue_attempts`, the same product the runtime's enqueue retry
428/// expresses — polled with the policy's backoff ladder. A single
429/// `ready_timeout` is the typical insert latency, but the start thread can
430/// be preempted past it under host oversubscription, and the cost of giving
431/// up early is a typed not-found for a workflow that is durably started.
432pub(crate) async fn wait_for_registered_handle(
433    registry: &crate::registry::Registry,
434    id: &WorkflowId,
435    run: &RunId,
436    policy: crate::runtime::SignalDeliveryConfig,
437) -> Result<Option<WorkflowHandle>, EngineError> {
438    let budget = policy
439        .ready_timeout
440        .saturating_mul(policy.max_enqueue_attempts.max(1));
441    let deadline = std::time::Instant::now() + budget;
442    let mut backoff = policy.initial_backoff;
443    loop {
444        if let Some(handle) = registry.get(id, run)? {
445            return Ok(Some(handle));
446        }
447        if std::time::Instant::now() >= deadline {
448            return Ok(None);
449        }
450        tokio::time::sleep(backoff).await;
451        let doubled = backoff.saturating_mul(2);
452        backoff = if doubled > policy.max_backoff {
453            policy.max_backoff
454        } else {
455            doubled
456        };
457    }
458}
459
460const fn event_family(event: &Event) -> EventFamily {
461    match event {
462        Event::WorkflowStarted { .. }
463        | Event::WorkflowCompleted { .. }
464        | Event::WorkflowFailed { .. }
465        | Event::WorkflowCancelled { .. }
466        | Event::WorkflowTimedOut { .. }
467        | Event::WorkflowContinuedAsNew { .. }
468        | Event::WorkflowReopened { .. }
469        | Event::WorkflowPaused { .. }
470        | Event::WorkflowResumed { .. }
471        | Event::SearchAttributesUpdated { .. } => EventFamily::Workflow,
472        Event::ActivityScheduled { .. }
473        | Event::ActivityStarted { .. }
474        | Event::ActivityAdoptionOffered { .. }
475        | Event::ActivityCompleted { .. }
476        | Event::ActivityFailed { .. }
477        | Event::ActivityAdvisoryExhausted { .. }
478        | Event::ActivityFallbackRouted { .. }
479        | Event::ActivityCancelled { .. } => EventFamily::Activity,
480        Event::TimerStarted { .. }
481        | Event::TimerFired { .. }
482        | Event::TimerCancelled { .. }
483        | Event::WithTimeoutCompleted { .. } => EventFamily::Timer,
484        Event::SignalReceived { .. } | Event::SignalSent { .. } => EventFamily::Signal,
485        Event::ChildWorkflowStarted { .. }
486        | Event::ChildWorkflowCompleted { .. }
487        | Event::ChildWorkflowFailed { .. }
488        | Event::ChildWorkflowCancelled { .. } => EventFamily::ChildWorkflow,
489        Event::ScheduleCreated { .. }
490        | Event::ScheduleUpdated { .. }
491        | Event::SchedulePaused { .. }
492        | Event::ScheduleResumed { .. }
493        | Event::ScheduleDeleted { .. }
494        | Event::ScheduleTriggered { .. } => EventFamily::Schedule,
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use std::sync::{Arc, Mutex};
501
502    use aion_core::{EventEnvelope, WorkflowStatus};
503    use aion_package::ContentHash;
504    use aion_store::visibility::VisibilityStore;
505    use aion_store::{EventStore, InMemoryStore};
506    use futures::{StreamExt, stream};
507    use serde_json::json;
508
509    use crate::durability::Recorder;
510    use crate::engine::api::EngineComponents;
511    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
512    use crate::{
513        Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog, WorkflowHandle,
514    };
515
516    use super::*;
517
518    #[derive(Debug, Default)]
519    struct SignalCapture {
520        calls: Mutex<Vec<(u64, String, Payload)>>,
521    }
522
523    #[async_trait]
524    impl SignalRouter for SignalCapture {
525        async fn route(
526            &self,
527            target: &WorkflowHandle,
528            name: String,
529            payload: Payload,
530        ) -> Result<(), EngineError> {
531            self.calls
532                .lock()
533                .map_err(|_| EngineError::RegistryPoisoned)?
534                .push((target.pid(), name, payload));
535            Ok(())
536        }
537    }
538
539    #[derive(Debug)]
540    struct QueryCapture {
541        calls: Mutex<Vec<(u64, String, Payload)>>,
542        reply: Payload,
543    }
544
545    #[async_trait]
546    impl QueryService for QueryCapture {
547        async fn query(
548            &self,
549            target: &WorkflowHandle,
550            name: String,
551            arguments: Payload,
552        ) -> Result<Payload, EngineError> {
553            self.calls
554                .lock()
555                .map_err(|_| EngineError::RegistryPoisoned)?
556                .push((target.pid(), name, arguments));
557            Ok(self.reply.clone())
558        }
559    }
560
561    #[derive(Debug)]
562    struct FakePublisher {
563        events: Vec<Event>,
564    }
565
566    impl EventPublisher for FakePublisher {
567        fn subscribe(
568            &self,
569            filter: EventFilter,
570        ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
571            let events = self
572                .events
573                .iter()
574                .filter(|event| filter.matches(event))
575                .cloned()
576                .map(Ok)
577                .collect::<Vec<_>>();
578            stream::iter(events).boxed()
579        }
580    }
581
582    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
583        Payload::from_json(&json!({ "label": label }))
584    }
585
586    fn engine_with_seams(
587        signal_router: Arc<dyn SignalRouter>,
588        query_service: Arc<dyn QueryService>,
589        event_publisher: Arc<dyn EventPublisher>,
590    ) -> Result<Engine, EngineError> {
591        let backing = Arc::new(InMemoryStore::default());
592        let store: Arc<dyn EventStore> = Arc::clone(&backing) as _;
593        let visibility_store: Arc<dyn VisibilityStore> = backing;
594        Ok(Engine::new(EngineComponents {
595            store,
596            visibility_store,
597            runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?),
598            catalog: Arc::new(WorkflowCatalog::new()),
599            registry: Arc::new(Registry::default()),
600            supervision: Arc::new(SupervisionTree::new()),
601            delegated: DelegatedSeams::new(signal_router, query_service, event_publisher),
602            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
603            search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
604            visibility_reconciliation_task: None,
605            deferred_startup_recovery: None,
606        }))
607    }
608
609    /// Record `WorkflowStarted` durably and build the matching handle
610    /// without inserting it into the registry — the exact state of the
611    /// registration birth window.
612    async fn recorded_active_handle(
613        engine: &Engine,
614    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
615        let workflow_id = WorkflowId::new_v4();
616        let run_id = RunId::new_v4();
617        let store = engine.store();
618        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
619        recorder
620            .record_workflow_started(
621                chrono::Utc::now(),
622                crate::durability::WorkflowStartRecord {
623                    workflow_type: "checkout".to_owned(),
624                    input: payload("input")?,
625                    run_id: run_id.clone(),
626                    parent_run_id: None,
627                    parent_workflow_id: None,
628                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
629                },
630            )
631            .await?;
632        Ok(WorkflowHandle::new(WorkflowHandleParts {
633            workflow_id,
634            run_id,
635            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
636            workflow_type: "checkout".to_owned(),
637            namespace: String::from("default"),
638            loaded_version: ContentHash::from_bytes([1; 32]),
639            cached_status: WorkflowStatus::Running,
640            residency: HandleResidency::Resident,
641            recorder,
642            completion: CompletionNotifier::new(),
643        }))
644    }
645
646    async fn insert_active_handle(
647        engine: &Engine,
648    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
649        let handle = recorded_active_handle(engine).await?;
650        engine.registry().insert(
651            (handle.workflow_id().clone(), handle.run_id().clone()),
652            handle.clone(),
653        )?;
654        Ok(handle)
655    }
656
657    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
658        EventEnvelope {
659            seq,
660            recorded_at: chrono::Utc::now(),
661            workflow_id: workflow_id.clone(),
662        }
663    }
664
665    /// Registration birth window (the 1/300 release-signal flake): the start
666    /// path records `WorkflowStarted` durably before it inserts the registry
667    /// handle, so a caller acting on observed history can signal before the
668    /// insert lands. The signal must wait the handle out within the delivery
669    /// policy budget — before the fix it returned `WorkflowNotFound`
670    /// immediately.
671    #[tokio::test(flavor = "multi_thread")]
672    async fn signal_inside_the_registration_birth_window_waits_for_the_handle()
673    -> Result<(), Box<dyn std::error::Error>> {
674        let signal = Arc::new(SignalCapture::default());
675        let engine = Arc::new(engine_with_seams(
676            signal.clone(),
677            Arc::new(DeferredQueryService),
678            Arc::new(DeferredEventPublisher),
679        )?);
680        let handle = recorded_active_handle(&engine).await?;
681
682        // The insert lands mid-wait, exactly as the start thread's does.
683        let late_engine = Arc::clone(&engine);
684        let late_handle = handle.clone();
685        let inserter = tokio::spawn(async move {
686            tokio::time::sleep(std::time::Duration::from_millis(15)).await;
687            late_engine.registry().insert(
688                (
689                    late_handle.workflow_id().clone(),
690                    late_handle.run_id().clone(),
691                ),
692                late_handle,
693            )
694        });
695
696        engine
697            .signal(
698                handle.workflow_id(),
699                handle.run_id(),
700                "approve",
701                payload("birth")?,
702            )
703            .await?;
704        inserter.await??;
705
706        let calls = signal
707            .calls
708            .lock()
709            .map_err(|_| EngineError::RegistryPoisoned)?;
710        assert_eq!(calls.len(), 1, "the signal must reach the routed handle");
711        drop(calls);
712        engine.shutdown()?;
713        Ok(())
714    }
715
716    /// The birth wait is bounded: a durably started run whose handle never
717    /// appears (its start failed, or its engine is gone) still fails typed
718    /// after the policy budget.
719    #[tokio::test(flavor = "multi_thread")]
720    async fn signal_for_a_started_run_with_no_handle_fails_typed_after_the_budget()
721    -> Result<(), Box<dyn std::error::Error>> {
722        let engine = engine_with_seams(
723            Arc::new(SignalCapture::default()),
724            Arc::new(DeferredQueryService),
725            Arc::new(DeferredEventPublisher),
726        )?;
727        let handle = recorded_active_handle(&engine).await?;
728
729        let outcome = engine
730            .signal(
731                handle.workflow_id(),
732                handle.run_id(),
733                "approve",
734                payload("never")?,
735            )
736            .await;
737
738        assert!(matches!(outcome, Err(EngineError::WorkflowNotFound { .. })));
739        engine.shutdown()?;
740        Ok(())
741    }
742
743    #[tokio::test]
744    async fn signal_delegates_to_router_and_unknown_returns_not_found()
745    -> Result<(), Box<dyn std::error::Error>> {
746        let signal = Arc::new(SignalCapture::default());
747        let engine = engine_with_seams(
748            signal.clone(),
749            Arc::new(DeferredQueryService),
750            Arc::new(DeferredEventPublisher),
751        )?;
752        let handle = insert_active_handle(&engine).await?;
753        let sent_payload = payload("signal")?;
754
755        engine
756            .signal(
757                handle.workflow_id(),
758                handle.run_id(),
759                "approve",
760                sent_payload.clone(),
761            )
762            .await?;
763
764        {
765            let calls = signal
766                .calls
767                .lock()
768                .map_err(|_| EngineError::RegistryPoisoned)?;
769            assert_eq!(
770                calls.as_slice(),
771                &[(handle.pid(), "approve".to_owned(), sent_payload)]
772            );
773        }
774        let unknown = engine
775            .signal(
776                &WorkflowId::new_v4(),
777                &RunId::new_v4(),
778                "approve",
779                payload("unknown")?,
780            )
781            .await;
782        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
783        engine.shutdown()?;
784        Ok(())
785    }
786
787    #[tokio::test]
788    async fn query_delegates_to_service_and_returns_payload()
789    -> Result<(), Box<dyn std::error::Error>> {
790        let reply = payload("reply")?;
791        let query = Arc::new(QueryCapture {
792            calls: Mutex::new(Vec::new()),
793            reply: reply.clone(),
794        });
795        let engine = engine_with_seams(
796            Arc::new(DeferredSignalRouter),
797            query.clone(),
798            Arc::new(DeferredEventPublisher),
799        )?;
800        let handle = insert_active_handle(&engine).await?;
801
802        let arguments = payload("arguments")?;
803        let returned = engine
804            .query(
805                handle.workflow_id(),
806                handle.run_id(),
807                "state",
808                arguments.clone(),
809            )
810            .await?;
811
812        assert_eq!(returned, reply);
813        let calls = query
814            .calls
815            .lock()
816            .map_err(|_| EngineError::RegistryPoisoned)?;
817        // The engine resolves the target and forwards the caller's arguments
818        // to the seam verbatim.
819        assert_eq!(
820            calls.as_slice(),
821            &[(handle.pid(), "state".to_owned(), arguments)]
822        );
823        drop(calls);
824        engine.shutdown()?;
825        Ok(())
826    }
827
828    #[tokio::test]
829    async fn query_terminal_run_is_not_running_and_unknown_is_not_found()
830    -> Result<(), Box<dyn std::error::Error>> {
831        let engine = engine_with_seams(
832            Arc::new(DeferredSignalRouter),
833            Arc::new(DeferredQueryService),
834            Arc::new(DeferredEventPublisher),
835        )?;
836        // Durably terminal run with no registry entry: a completed workflow.
837        let workflow_id = WorkflowId::new_v4();
838        let run_id = aion_core::RunId::new_v4();
839        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
840        recorder
841            .record_workflow_started(
842                chrono::Utc::now(),
843                crate::durability::WorkflowStartRecord {
844                    workflow_type: "checkout".to_owned(),
845                    input: payload("input")?,
846                    run_id: run_id.clone(),
847                    parent_run_id: None,
848                    parent_workflow_id: None,
849                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
850                },
851            )
852            .await?;
853        recorder
854            .record_workflow_completed(chrono::Utc::now(), payload("result")?)
855            .await?;
856
857        let terminal = engine
858            .query(&workflow_id, &run_id, "state", Payload::json_null())
859            .await;
860        assert!(matches!(
861            terminal,
862            Err(EngineError::Query(crate::query::QueryError::NotRunning(id))) if id == workflow_id
863        ));
864
865        let unknown = engine
866            .query(
867                &WorkflowId::new_v4(),
868                &RunId::new_v4(),
869                "state",
870                Payload::json_null(),
871            )
872            .await;
873        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
874        engine.shutdown()?;
875        Ok(())
876    }
877
878    #[tokio::test]
879    async fn subscribe_delegates_to_publisher_stream_with_filter()
880    -> Result<(), Box<dyn std::error::Error>> {
881        let workflow_id = WorkflowId::new_v4();
882        let other_id = WorkflowId::new_v4();
883        let matching = Event::SignalReceived {
884            envelope: envelope(1, &workflow_id),
885            name: "approved".to_owned(),
886            payload: payload("signal")?,
887        };
888        let filtered = Event::WorkflowStarted {
889            envelope: envelope(1, &other_id),
890            workflow_type: "checkout".to_owned(),
891            input: payload("input")?,
892            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
893            parent_run_id: None,
894            parent_workflow_id: None,
895            package_version: aion_core::PackageVersion::new("a".repeat(64)),
896        };
897        let engine = engine_with_seams(
898            Arc::new(DeferredSignalRouter),
899            Arc::new(DeferredQueryService),
900            Arc::new(FakePublisher {
901                events: vec![matching.clone(), filtered],
902            }),
903        )?;
904
905        let events = engine
906            .subscribe(EventFilter {
907                workflow_id: Some(workflow_id),
908                run: None,
909                family: Some(EventFamily::Signal),
910            })
911            .collect::<Vec<_>>()
912            .await;
913
914        assert_eq!(events, vec![Ok(matching)]);
915        engine.shutdown()?;
916        Ok(())
917    }
918}