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}