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