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::ActivityCancelled { .. } => EventFamily::Activity,
479        Event::TimerStarted { .. }
480        | Event::TimerFired { .. }
481        | Event::TimerCancelled { .. }
482        | Event::WithTimeoutCompleted { .. } => EventFamily::Timer,
483        Event::SignalReceived { .. } | Event::SignalSent { .. } => EventFamily::Signal,
484        Event::ChildWorkflowStarted { .. }
485        | Event::ChildWorkflowCompleted { .. }
486        | Event::ChildWorkflowFailed { .. }
487        | Event::ChildWorkflowCancelled { .. } => EventFamily::ChildWorkflow,
488        Event::ScheduleCreated { .. }
489        | Event::ScheduleUpdated { .. }
490        | Event::SchedulePaused { .. }
491        | Event::ScheduleResumed { .. }
492        | Event::ScheduleDeleted { .. }
493        | Event::ScheduleTriggered { .. } => EventFamily::Schedule,
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use std::sync::{Arc, Mutex};
500
501    use aion_core::{EventEnvelope, WorkflowStatus};
502    use aion_package::ContentHash;
503    use aion_store::visibility::VisibilityStore;
504    use aion_store::{EventStore, InMemoryStore};
505    use futures::{StreamExt, stream};
506    use serde_json::json;
507
508    use crate::durability::Recorder;
509    use crate::engine::api::EngineComponents;
510    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
511    use crate::{
512        Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog, WorkflowHandle,
513    };
514
515    use super::*;
516
517    #[derive(Debug, Default)]
518    struct SignalCapture {
519        calls: Mutex<Vec<(u64, String, Payload)>>,
520    }
521
522    #[async_trait]
523    impl SignalRouter for SignalCapture {
524        async fn route(
525            &self,
526            target: &WorkflowHandle,
527            name: String,
528            payload: Payload,
529        ) -> Result<(), EngineError> {
530            self.calls
531                .lock()
532                .map_err(|_| EngineError::RegistryPoisoned)?
533                .push((target.pid(), name, payload));
534            Ok(())
535        }
536    }
537
538    #[derive(Debug)]
539    struct QueryCapture {
540        calls: Mutex<Vec<(u64, String, Payload)>>,
541        reply: Payload,
542    }
543
544    #[async_trait]
545    impl QueryService for QueryCapture {
546        async fn query(
547            &self,
548            target: &WorkflowHandle,
549            name: String,
550            arguments: Payload,
551        ) -> Result<Payload, EngineError> {
552            self.calls
553                .lock()
554                .map_err(|_| EngineError::RegistryPoisoned)?
555                .push((target.pid(), name, arguments));
556            Ok(self.reply.clone())
557        }
558    }
559
560    #[derive(Debug)]
561    struct FakePublisher {
562        events: Vec<Event>,
563    }
564
565    impl EventPublisher for FakePublisher {
566        fn subscribe(
567            &self,
568            filter: EventFilter,
569        ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
570            let events = self
571                .events
572                .iter()
573                .filter(|event| filter.matches(event))
574                .cloned()
575                .map(Ok)
576                .collect::<Vec<_>>();
577            stream::iter(events).boxed()
578        }
579    }
580
581    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
582        Payload::from_json(&json!({ "label": label }))
583    }
584
585    fn engine_with_seams(
586        signal_router: Arc<dyn SignalRouter>,
587        query_service: Arc<dyn QueryService>,
588        event_publisher: Arc<dyn EventPublisher>,
589    ) -> Result<Engine, EngineError> {
590        let backing = Arc::new(InMemoryStore::default());
591        let store: Arc<dyn EventStore> = Arc::clone(&backing) as _;
592        let visibility_store: Arc<dyn VisibilityStore> = backing;
593        Ok(Engine::new(EngineComponents {
594            store,
595            visibility_store,
596            runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?),
597            catalog: Arc::new(WorkflowCatalog::new()),
598            registry: Arc::new(Registry::default()),
599            supervision: Arc::new(SupervisionTree::new()),
600            delegated: DelegatedSeams::new(signal_router, query_service, event_publisher),
601            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
602            search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
603            visibility_reconciliation_task: None,
604            deferred_startup_recovery: None,
605        }))
606    }
607
608    /// Record `WorkflowStarted` durably and build the matching handle
609    /// without inserting it into the registry — the exact state of the
610    /// registration birth window.
611    async fn recorded_active_handle(
612        engine: &Engine,
613    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
614        let workflow_id = WorkflowId::new_v4();
615        let run_id = RunId::new_v4();
616        let store = engine.store();
617        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
618        recorder
619            .record_workflow_started(
620                chrono::Utc::now(),
621                crate::durability::WorkflowStartRecord {
622                    workflow_type: "checkout".to_owned(),
623                    input: payload("input")?,
624                    run_id: run_id.clone(),
625                    parent_run_id: None,
626                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
627                },
628            )
629            .await?;
630        Ok(WorkflowHandle::new(WorkflowHandleParts {
631            workflow_id,
632            run_id,
633            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
634            workflow_type: "checkout".to_owned(),
635            namespace: String::from("default"),
636            loaded_version: ContentHash::from_bytes([1; 32]),
637            cached_status: WorkflowStatus::Running,
638            residency: HandleResidency::Resident,
639            recorder,
640            completion: CompletionNotifier::new(),
641        }))
642    }
643
644    async fn insert_active_handle(
645        engine: &Engine,
646    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
647        let handle = recorded_active_handle(engine).await?;
648        engine.registry().insert(
649            (handle.workflow_id().clone(), handle.run_id().clone()),
650            handle.clone(),
651        )?;
652        Ok(handle)
653    }
654
655    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
656        EventEnvelope {
657            seq,
658            recorded_at: chrono::Utc::now(),
659            workflow_id: workflow_id.clone(),
660        }
661    }
662
663    /// Registration birth window (the 1/300 release-signal flake): the start
664    /// path records `WorkflowStarted` durably before it inserts the registry
665    /// handle, so a caller acting on observed history can signal before the
666    /// insert lands. The signal must wait the handle out within the delivery
667    /// policy budget — before the fix it returned `WorkflowNotFound`
668    /// immediately.
669    #[tokio::test(flavor = "multi_thread")]
670    async fn signal_inside_the_registration_birth_window_waits_for_the_handle()
671    -> Result<(), Box<dyn std::error::Error>> {
672        let signal = Arc::new(SignalCapture::default());
673        let engine = Arc::new(engine_with_seams(
674            signal.clone(),
675            Arc::new(DeferredQueryService),
676            Arc::new(DeferredEventPublisher),
677        )?);
678        let handle = recorded_active_handle(&engine).await?;
679
680        // The insert lands mid-wait, exactly as the start thread's does.
681        let late_engine = Arc::clone(&engine);
682        let late_handle = handle.clone();
683        let inserter = tokio::spawn(async move {
684            tokio::time::sleep(std::time::Duration::from_millis(15)).await;
685            late_engine.registry().insert(
686                (
687                    late_handle.workflow_id().clone(),
688                    late_handle.run_id().clone(),
689                ),
690                late_handle,
691            )
692        });
693
694        engine
695            .signal(
696                handle.workflow_id(),
697                handle.run_id(),
698                "approve",
699                payload("birth")?,
700            )
701            .await?;
702        inserter.await??;
703
704        let calls = signal
705            .calls
706            .lock()
707            .map_err(|_| EngineError::RegistryPoisoned)?;
708        assert_eq!(calls.len(), 1, "the signal must reach the routed handle");
709        drop(calls);
710        engine.shutdown()?;
711        Ok(())
712    }
713
714    /// The birth wait is bounded: a durably started run whose handle never
715    /// appears (its start failed, or its engine is gone) still fails typed
716    /// after the policy budget.
717    #[tokio::test(flavor = "multi_thread")]
718    async fn signal_for_a_started_run_with_no_handle_fails_typed_after_the_budget()
719    -> Result<(), Box<dyn std::error::Error>> {
720        let engine = engine_with_seams(
721            Arc::new(SignalCapture::default()),
722            Arc::new(DeferredQueryService),
723            Arc::new(DeferredEventPublisher),
724        )?;
725        let handle = recorded_active_handle(&engine).await?;
726
727        let outcome = engine
728            .signal(
729                handle.workflow_id(),
730                handle.run_id(),
731                "approve",
732                payload("never")?,
733            )
734            .await;
735
736        assert!(matches!(outcome, Err(EngineError::WorkflowNotFound { .. })));
737        engine.shutdown()?;
738        Ok(())
739    }
740
741    #[tokio::test]
742    async fn signal_delegates_to_router_and_unknown_returns_not_found()
743    -> Result<(), Box<dyn std::error::Error>> {
744        let signal = Arc::new(SignalCapture::default());
745        let engine = engine_with_seams(
746            signal.clone(),
747            Arc::new(DeferredQueryService),
748            Arc::new(DeferredEventPublisher),
749        )?;
750        let handle = insert_active_handle(&engine).await?;
751        let sent_payload = payload("signal")?;
752
753        engine
754            .signal(
755                handle.workflow_id(),
756                handle.run_id(),
757                "approve",
758                sent_payload.clone(),
759            )
760            .await?;
761
762        {
763            let calls = signal
764                .calls
765                .lock()
766                .map_err(|_| EngineError::RegistryPoisoned)?;
767            assert_eq!(
768                calls.as_slice(),
769                &[(handle.pid(), "approve".to_owned(), sent_payload)]
770            );
771        }
772        let unknown = engine
773            .signal(
774                &WorkflowId::new_v4(),
775                &RunId::new_v4(),
776                "approve",
777                payload("unknown")?,
778            )
779            .await;
780        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
781        engine.shutdown()?;
782        Ok(())
783    }
784
785    #[tokio::test]
786    async fn query_delegates_to_service_and_returns_payload()
787    -> Result<(), Box<dyn std::error::Error>> {
788        let reply = payload("reply")?;
789        let query = Arc::new(QueryCapture {
790            calls: Mutex::new(Vec::new()),
791            reply: reply.clone(),
792        });
793        let engine = engine_with_seams(
794            Arc::new(DeferredSignalRouter),
795            query.clone(),
796            Arc::new(DeferredEventPublisher),
797        )?;
798        let handle = insert_active_handle(&engine).await?;
799
800        let arguments = payload("arguments")?;
801        let returned = engine
802            .query(
803                handle.workflow_id(),
804                handle.run_id(),
805                "state",
806                arguments.clone(),
807            )
808            .await?;
809
810        assert_eq!(returned, reply);
811        let calls = query
812            .calls
813            .lock()
814            .map_err(|_| EngineError::RegistryPoisoned)?;
815        // The engine resolves the target and forwards the caller's arguments
816        // to the seam verbatim.
817        assert_eq!(
818            calls.as_slice(),
819            &[(handle.pid(), "state".to_owned(), arguments)]
820        );
821        drop(calls);
822        engine.shutdown()?;
823        Ok(())
824    }
825
826    #[tokio::test]
827    async fn query_terminal_run_is_not_running_and_unknown_is_not_found()
828    -> Result<(), Box<dyn std::error::Error>> {
829        let engine = engine_with_seams(
830            Arc::new(DeferredSignalRouter),
831            Arc::new(DeferredQueryService),
832            Arc::new(DeferredEventPublisher),
833        )?;
834        // Durably terminal run with no registry entry: a completed workflow.
835        let workflow_id = WorkflowId::new_v4();
836        let run_id = aion_core::RunId::new_v4();
837        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
838        recorder
839            .record_workflow_started(
840                chrono::Utc::now(),
841                crate::durability::WorkflowStartRecord {
842                    workflow_type: "checkout".to_owned(),
843                    input: payload("input")?,
844                    run_id: run_id.clone(),
845                    parent_run_id: None,
846                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
847                },
848            )
849            .await?;
850        recorder
851            .record_workflow_completed(chrono::Utc::now(), payload("result")?)
852            .await?;
853
854        let terminal = engine
855            .query(&workflow_id, &run_id, "state", Payload::json_null())
856            .await;
857        assert!(matches!(
858            terminal,
859            Err(EngineError::Query(crate::query::QueryError::NotRunning(id))) if id == workflow_id
860        ));
861
862        let unknown = engine
863            .query(
864                &WorkflowId::new_v4(),
865                &RunId::new_v4(),
866                "state",
867                Payload::json_null(),
868            )
869            .await;
870        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
871        engine.shutdown()?;
872        Ok(())
873    }
874
875    #[tokio::test]
876    async fn subscribe_delegates_to_publisher_stream_with_filter()
877    -> Result<(), Box<dyn std::error::Error>> {
878        let workflow_id = WorkflowId::new_v4();
879        let other_id = WorkflowId::new_v4();
880        let matching = Event::SignalReceived {
881            envelope: envelope(1, &workflow_id),
882            name: "approved".to_owned(),
883            payload: payload("signal")?,
884        };
885        let filtered = Event::WorkflowStarted {
886            envelope: envelope(1, &other_id),
887            workflow_type: "checkout".to_owned(),
888            input: payload("input")?,
889            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
890            parent_run_id: None,
891            package_version: aion_core::PackageVersion::new("a".repeat(64)),
892        };
893        let engine = engine_with_seams(
894            Arc::new(DeferredSignalRouter),
895            Arc::new(DeferredQueryService),
896            Arc::new(FakePublisher {
897                events: vec![matching.clone(), filtered],
898            }),
899        )?;
900
901        let events = engine
902            .subscribe(EventFilter {
903                workflow_id: Some(workflow_id),
904                run: None,
905                family: Some(EventFamily::Signal),
906            })
907            .collect::<Vec<_>>()
908            .await;
909
910        assert_eq!(events, vec![Ok(matching)]);
911        engine.shutdown()?;
912        Ok(())
913    }
914}