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