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}