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