Skip to main content

aion/engine/
delegated.rs

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