Skip to main content

ironflow_runtime/trigger/
mod.rs

1//! Pluggable trigger sources for starting workflow runs.
2//!
3//! A [`Trigger`] listens for external or internal signals and emits
4//! [`TriggerEvent`]s through a [`TriggerSink`]. The runtime starts all
5//! registered triggers alongside the HTTP server and cron scheduler, and
6//! forwards their events to the configured handler.
7//!
8//! Built-in triggers:
9//! - `EventTrigger` -- reacts to internal domain events (workflow chaining).
10//! - `NatsTrigger` -- consumes messages from a NATS JetStream subject
11//!   (behind the `trigger-nats` feature flag).
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use ironflow_runtime::trigger::{Trigger, TriggerSink, TriggerEvent};
17//! use ironflow_runtime::trigger::event::{EventTrigger, EventTriggerRule};
18//! use ironflow_store::entities::EventKind;
19//!
20//! let trigger = EventTrigger::new(vec![
21//!     EventTriggerRule {
22//!         on_event: EventKind::RunFailed,
23//!         source_workflow: "deploy".to_string(),
24//!         target_workflow: "rollback".to_string(),
25//!         max_chain_depth: 3,
26//!     },
27//! ]);
28//! ```
29
30pub mod event;
31#[cfg(feature = "trigger-nats")]
32pub mod nats;
33
34use std::future::Future;
35use std::pin::Pin;
36
37use serde_json::Value;
38use tokio::sync::mpsc;
39use tokio_util::sync::CancellationToken;
40
41use ironflow_store::entities::TriggerKind;
42
43/// Future returned by [`Trigger::start`].
44pub type TriggerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), TriggerError>> + Send + 'a>>;
45
46/// A source of workflow run triggers.
47///
48/// Implementations listen for some signal (domain event, message queue,
49/// external webhook, etc.) and send [`TriggerEvent`]s through the
50/// [`TriggerSink`] to request run creation.
51///
52/// # Lifecycle
53///
54/// 1. The runtime calls [`start`](Trigger::start) with a sink and a
55///    cancellation token.
56/// 2. The trigger runs until the token is cancelled (graceful shutdown)
57///    or an unrecoverable error occurs.
58/// 3. Dropping the trigger releases all resources.
59///
60/// # Examples
61///
62/// ```no_run
63/// use ironflow_runtime::trigger::{Trigger, TriggerFuture, TriggerSink, TriggerError};
64/// use tokio_util::sync::CancellationToken;
65///
66/// struct MyTrigger;
67///
68/// impl Trigger for MyTrigger {
69///     fn name(&self) -> &str { "my-trigger" }
70///
71///     fn start<'a>(
72///         &'a self,
73///         _sink: TriggerSink,
74///         token: &'a CancellationToken,
75///     ) -> TriggerFuture<'a> {
76///         Box::pin(async move {
77///             token.cancelled().await;
78///             Ok(())
79///         })
80///     }
81/// }
82/// ```
83pub trait Trigger: Send + Sync {
84    /// Human-readable name for logging.
85    fn name(&self) -> &str;
86
87    /// Start the trigger.
88    ///
89    /// The implementation should send [`TriggerEvent`]s through `sink`
90    /// whenever a run should be created, and return when `token` is
91    /// cancelled.
92    fn start<'a>(&'a self, sink: TriggerSink, token: &'a CancellationToken) -> TriggerFuture<'a>;
93}
94
95/// Error type for trigger operations.
96///
97/// # Examples
98///
99/// ```
100/// use ironflow_runtime::trigger::TriggerError;
101///
102/// let err = TriggerError::Failed("connection lost".to_string());
103/// assert!(err.to_string().contains("connection lost"));
104/// ```
105#[derive(Debug, thiserror::Error)]
106pub enum TriggerError {
107    /// A trigger encountered an unrecoverable error.
108    #[error("trigger failed: {0}")]
109    Failed(String),
110}
111
112/// A request to create a workflow run, emitted by a [`Trigger`].
113///
114/// # Examples
115///
116/// ```
117/// use ironflow_runtime::trigger::TriggerEvent;
118/// use ironflow_store::entities::TriggerKind;
119/// use serde_json::json;
120///
121/// let event = TriggerEvent {
122///     workflow_name: "rollback".to_string(),
123///     payload: json!({"source": "deploy"}),
124///     trigger_kind: TriggerKind::Manual,
125/// };
126/// assert_eq!(event.workflow_name, "rollback");
127/// ```
128#[derive(Debug, Clone)]
129pub struct TriggerEvent {
130    /// The workflow to run.
131    pub workflow_name: String,
132    /// Payload passed to the new run.
133    pub payload: Value,
134    /// How the run was triggered (stored on the run record).
135    pub trigger_kind: TriggerKind,
136}
137
138/// Channel endpoint for triggers to emit [`TriggerEvent`]s.
139///
140/// Obtained from [`TriggerSink::channel`] and passed to
141/// [`Trigger::start`].
142///
143/// # Examples
144///
145/// ```
146/// use ironflow_runtime::trigger::{TriggerSink, TriggerEvent};
147/// use ironflow_store::entities::TriggerKind;
148/// use serde_json::json;
149///
150/// let (sink, mut rx) = TriggerSink::channel(16);
151///
152/// # tokio_test::block_on(async {
153/// sink.send(TriggerEvent {
154///     workflow_name: "deploy".to_string(),
155///     payload: json!({}),
156///     trigger_kind: TriggerKind::Manual,
157/// }).await.unwrap();
158///
159/// let event = rx.recv().await.unwrap();
160/// assert_eq!(event.workflow_name, "deploy");
161/// # });
162/// ```
163#[derive(Clone)]
164pub struct TriggerSink {
165    tx: mpsc::Sender<TriggerEvent>,
166}
167
168impl TriggerSink {
169    /// Create a sink/receiver pair with the given buffer capacity.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use ironflow_runtime::trigger::TriggerSink;
175    ///
176    /// let (sink, rx) = TriggerSink::channel(32);
177    /// drop(rx);
178    /// ```
179    pub fn channel(buffer: usize) -> (Self, mpsc::Receiver<TriggerEvent>) {
180        let (tx, rx) = mpsc::channel(buffer);
181        (Self { tx }, rx)
182    }
183
184    /// Send a trigger event.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if the receiver has been dropped.
189    pub async fn send(&self, event: TriggerEvent) -> Result<(), TriggerError> {
190        self.tx
191            .send(event)
192            .await
193            .map_err(|e| TriggerError::Failed(format!("sink closed: {e}")))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201
202    #[test]
203    fn trigger_error_display() {
204        let err = TriggerError::Failed("boom".to_string());
205        assert_eq!(err.to_string(), "trigger failed: boom");
206    }
207
208    #[tokio::test]
209    async fn sink_sends_and_receives() {
210        let (sink, mut rx) = TriggerSink::channel(4);
211        sink.send(TriggerEvent {
212            workflow_name: "deploy".to_string(),
213            payload: json!({"key": "val"}),
214            trigger_kind: TriggerKind::Manual,
215        })
216        .await
217        .unwrap();
218
219        let event = rx.recv().await.unwrap();
220        assert_eq!(event.workflow_name, "deploy");
221        assert_eq!(event.payload, json!({"key": "val"}));
222    }
223
224    #[tokio::test]
225    async fn sink_errors_when_receiver_dropped() {
226        let (sink, rx) = TriggerSink::channel(1);
227        drop(rx);
228
229        let result = sink
230            .send(TriggerEvent {
231                workflow_name: "x".to_string(),
232                payload: json!(null),
233                trigger_kind: TriggerKind::Manual,
234            })
235            .await;
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn trigger_event_clone() {
241        let event = TriggerEvent {
242            workflow_name: "w".to_string(),
243            payload: json!(42),
244            trigger_kind: TriggerKind::Api,
245        };
246        let cloned = event.clone();
247        assert_eq!(cloned.workflow_name, "w");
248    }
249}