Skip to main content

ironflow_engine/
signal.rs

1//! Signals -- external messages, named and keyed, that resume waiting runs.
2//!
3//! A workflow waits with
4//! [`WorkflowContext::wait_for_signal`](crate::context::WorkflowContext::wait_for_signal);
5//! a producer (a webhook handler, a script, another service) delivers with
6//! [`Engine::send_signal`](crate::engine::Engine::send_signal) or
7//! `POST /api/v1/signals`. Another workflow delivers with
8//! [`WorkflowContext::send_signal`](crate::context::WorkflowContext::send_signal),
9//! for the signals its handler declares in
10//! [`sent_signals`](crate::handler::WorkflowHandler::sent_signals).
11//!
12//! The **name** says what happened (`"ci.pipeline_finished"`), the **key**
13//! says which occurrence (a commit SHA). Every run waiting on the same
14//! `(name, key)` pair receives the signal.
15//!
16//! # Examples
17//!
18//! ```
19//! use ironflow_engine::signal::Signal;
20//! use schemars::JsonSchema;
21//! use serde::{Deserialize, Serialize};
22//!
23//! #[derive(Serialize, Deserialize, JsonSchema)]
24//! struct PipelineFinished {
25//!     status: String,
26//! }
27//!
28//! impl Signal for PipelineFinished {
29//!     const NAME: &'static str = "ci.pipeline_finished";
30//! }
31//!
32//! assert_eq!(PipelineFinished::NAME, "ci.pipeline_finished");
33//! ```
34
35use std::future::Future;
36use std::pin::Pin;
37
38use jsonschema::validator_for;
39use schemars::JsonSchema;
40use serde::de::DeserializeOwned;
41use serde::{Deserialize, Serialize};
42use serde_json::{Value, json};
43use uuid::Uuid;
44
45use ironflow_store::entities::{NewSignal, Signal as StoredSignal};
46
47use crate::error::EngineError;
48
49/// A typed signal a workflow can wait for.
50///
51/// The payload is the type itself: it is serialized when sent, validated
52/// against its JSON schema on delivery, and deserialized for the waiting
53/// handler.
54///
55/// # Examples
56///
57/// ```
58/// use ironflow_engine::signal::Signal;
59/// use schemars::JsonSchema;
60/// use serde::{Deserialize, Serialize};
61///
62/// #[derive(Serialize, Deserialize, JsonSchema)]
63/// struct PipelineFinished {
64///     status: String,
65///     pipeline_id: u64,
66/// }
67///
68/// impl Signal for PipelineFinished {
69///     const NAME: &'static str = "ci.pipeline_finished";
70/// }
71/// ```
72pub trait Signal: DeserializeOwned + Serialize + JsonSchema {
73    /// Signal name, e.g. `"ci.pipeline_finished"`. Must not be empty.
74    const NAME: &'static str;
75}
76
77/// Maximum length, in characters, of a signal name, key or idempotency ID.
78pub const MAX_SIGNAL_FIELD_LEN: usize = 255;
79
80/// The signals a workflow may send with
81/// [`WorkflowContext::send_signal`](crate::context::WorkflowContext::send_signal).
82///
83/// Built from the signal types themselves, never from hand-written names.
84/// It keeps a workflow from sending, by mistake, a signal that is not its
85/// own; it is not a security boundary: code running in a worker can reach the
86/// store and the internal API anyway.
87///
88/// # Examples
89///
90/// ```
91/// use ironflow_engine::signal::{SentSignals, Signal};
92/// use schemars::JsonSchema;
93/// use serde::{Deserialize, Serialize};
94///
95/// #[derive(Serialize, Deserialize, JsonSchema)]
96/// struct HumanReplied {
97///     body: String,
98/// }
99///
100/// impl Signal for HumanReplied {
101///     const NAME: &'static str = "gitlab.human_replied";
102/// }
103///
104/// let sent = SentSignals::new().allow::<HumanReplied>();
105/// assert!(sent.allows(HumanReplied::NAME));
106/// assert!(!sent.allows("ci.pipeline_finished"));
107/// ```
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub struct SentSignals {
110    names: Vec<&'static str>,
111}
112
113impl SentSignals {
114    /// An empty declaration: no signal may be sent.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use ironflow_engine::signal::SentSignals;
120    ///
121    /// assert!(SentSignals::new().names().is_empty());
122    /// ```
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Allow sending `S`.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use ironflow_engine::signal::{SentSignals, Signal};
133    /// use schemars::JsonSchema;
134    /// use serde::{Deserialize, Serialize};
135    ///
136    /// #[derive(Serialize, Deserialize, JsonSchema)]
137    /// struct Deployed {
138    ///     version: String,
139    /// }
140    ///
141    /// impl Signal for Deployed {
142    ///     const NAME: &'static str = "deploy.done";
143    /// }
144    ///
145    /// let sent = SentSignals::new().allow::<Deployed>().allow::<Deployed>();
146    /// assert_eq!(sent.names(), ["deploy.done"]);
147    /// ```
148    pub fn allow<S: Signal>(mut self) -> Self {
149        if !self.names.contains(&S::NAME) {
150            self.names.push(S::NAME);
151        }
152        self
153    }
154
155    /// Whether the signal named `name` may be sent.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use ironflow_engine::signal::SentSignals;
161    ///
162    /// assert!(!SentSignals::new().allows("deploy.done"));
163    /// ```
164    pub fn allows(&self, name: &str) -> bool {
165        self.names.contains(&name)
166    }
167
168    /// Names of the allowed signals, in declaration order.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ironflow_engine::signal::SentSignals;
174    ///
175    /// let empty: &[&str] = &[];
176    /// assert_eq!(SentSignals::new().names(), empty);
177    /// ```
178    pub fn names(&self) -> &[&'static str] {
179        &self.names
180    }
181}
182
183/// The run and step a signal was sent from, recorded on the
184/// [`SignalReceived`](crate::notify::Event::SignalReceived) event.
185///
186/// # Examples
187///
188/// ```
189/// use ironflow_engine::signal::SignalOrigin;
190/// use uuid::Uuid;
191///
192/// let origin = SignalOrigin { run_id: Uuid::now_v7(), step_id: Uuid::now_v7() };
193/// assert_ne!(origin.run_id, origin.step_id);
194/// ```
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196pub struct SignalOrigin {
197    /// The sending run.
198    pub run_id: Uuid,
199    /// The send step of that run.
200    pub step_id: Uuid,
201}
202
203/// Future returned by [`SignalSender::send`].
204pub type SignalSendFuture<'a> =
205    Pin<Box<dyn Future<Output = Result<SignalDelivery, EngineError>> + Send + 'a>>;
206
207/// Where [`WorkflowContext::send_signal`](crate::context::WorkflowContext::send_signal)
208/// delivers.
209///
210/// An engine delivers in its own process by default, through
211/// [`Engine::deliver_signal_from`](crate::engine::Engine::deliver_signal_from).
212/// A worker, whose store cannot deliver, sets one that goes through the API
213/// with [`Engine::with_signal_sender`](crate::engine::Engine::with_signal_sender).
214///
215/// # Examples
216///
217/// ```no_run
218/// use std::sync::Arc;
219/// use ironflow_engine::engine::Engine;
220/// use ironflow_engine::signal::{SignalOrigin, SignalSendFuture, SignalSender};
221/// use ironflow_store::models::NewSignal;
222///
223/// /// Delivers through another engine, sharing the same store.
224/// struct Through(Arc<Engine>);
225///
226/// impl SignalSender for Through {
227///     fn send(&self, signal: NewSignal, origin: SignalOrigin) -> SignalSendFuture<'_> {
228///         Box::pin(self.0.deliver_signal_from(signal, Some(origin)))
229///     }
230/// }
231/// ```
232pub trait SignalSender: Send + Sync {
233    /// Deliver `signal`, sent by the step `origin`.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`EngineError::InvalidSignal`] for a malformed signal, and
238    /// [`EngineError::Store`] when it cannot be stored or delivered.
239    fn send(&self, signal: NewSignal, origin: SignalOrigin) -> SignalSendFuture<'_>;
240}
241
242/// Key of the payload JSON schema in a signal step's input.
243pub const SIGNAL_SCHEMA_KEY: &str = "schema";
244
245/// Key of the timeout flag in a signal step's output.
246pub const SIGNAL_TIMED_OUT_KEY: &str = "timed_out";
247
248/// Result of delivering a signal.
249///
250/// # Examples
251///
252/// ```
253/// use ironflow_engine::signal::SignalDelivery;
254/// use uuid::Uuid;
255///
256/// let delivery = SignalDelivery {
257///     signal_id: Uuid::now_v7(),
258///     duplicate: false,
259///     resumed: Vec::new(),
260///     rejected: Vec::new(),
261/// };
262/// assert!(delivery.resumed.is_empty());
263/// ```
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct SignalDelivery {
266    /// ID of the stored signal (the pre-existing one on a duplicate).
267    pub signal_id: Uuid,
268    /// `true` when the idempotency ID was already used: nothing was delivered.
269    pub duplicate: bool,
270    /// Waiting steps the signal resolved.
271    pub resumed: Vec<SignalResumed>,
272    /// Waiting steps whose payload schema the signal did not match. These
273    /// steps keep waiting.
274    pub rejected: Vec<SignalRejected>,
275}
276
277/// A waiting step resolved by a signal.
278///
279/// # Examples
280///
281/// ```
282/// use ironflow_engine::signal::SignalResumed;
283/// use uuid::Uuid;
284///
285/// let resumed = SignalResumed { run_id: Uuid::now_v7(), step_id: Uuid::now_v7() };
286/// assert_ne!(resumed.run_id, resumed.step_id);
287/// ```
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289pub struct SignalResumed {
290    /// Run owning the step.
291    pub run_id: Uuid,
292    /// The resolved signal step.
293    pub step_id: Uuid,
294}
295
296/// A waiting step a signal could not resolve.
297///
298/// # Examples
299///
300/// ```
301/// use ironflow_engine::signal::SignalRejected;
302/// use uuid::Uuid;
303///
304/// let rejected = SignalRejected {
305///     run_id: Uuid::now_v7(),
306///     step_id: Uuid::now_v7(),
307///     error: "\"status\" is a required property".to_string(),
308/// };
309/// assert!(rejected.error.contains("status"));
310/// ```
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct SignalRejected {
313    /// Run owning the step.
314    pub run_id: Uuid,
315    /// The step that keeps waiting.
316    pub step_id: Uuid,
317    /// Why the payload was refused.
318    pub error: String,
319}
320
321/// Output recorded on a signal step resolved by `signal`.
322pub(crate) fn received_output(signal: &StoredSignal) -> Value {
323    json!({
324        SIGNAL_TIMED_OUT_KEY: false,
325        "signal_id": signal.id,
326        "payload": signal.payload,
327    })
328}
329
330/// Output recorded on a signal step whose deadline passed.
331pub(crate) fn timed_out_output() -> Value {
332    json!({ SIGNAL_TIMED_OUT_KEY: true })
333}
334
335/// Reject a signal whose name, key or idempotency ID is empty or longer than
336/// [`MAX_SIGNAL_FIELD_LEN`].
337pub(crate) fn validate_signal(signal: &NewSignal) -> Result<(), EngineError> {
338    let fields = [
339        ("name", Some(&signal.name)),
340        ("key", Some(&signal.key)),
341        ("idempotency_id", signal.idempotency_id.as_ref()),
342    ];
343    for (field, value) in fields {
344        let Some(value) = value else { continue };
345        if value.trim().is_empty() {
346            return Err(EngineError::InvalidSignal(format!(
347                "signal {field} must not be empty"
348            )));
349        }
350        if value.chars().count() > MAX_SIGNAL_FIELD_LEN {
351            return Err(EngineError::InvalidSignal(format!(
352                "signal {field} must be at most {MAX_SIGNAL_FIELD_LEN} characters"
353            )));
354        }
355    }
356    Ok(())
357}
358
359/// Validate `payload` against a JSON schema, joining every violation.
360pub(crate) fn validate_payload(schema: &Value, payload: &Value) -> Result<(), String> {
361    let validator = validator_for(schema).map_err(|e| format!("invalid payload schema: {e}"))?;
362    let errors: Vec<String> = validator
363        .iter_errors(payload)
364        .map(|e| e.to_string())
365        .collect();
366    if errors.is_empty() {
367        Ok(())
368    } else {
369        Err(errors.join("; "))
370    }
371}
372
373/// Validate `payload` against the schema a signal step stored in its input.
374pub(crate) fn validate_step_payload(input: Option<&Value>, payload: &Value) -> Result<(), String> {
375    let schema = input
376        .and_then(|i| i.get(SIGNAL_SCHEMA_KEY))
377        .ok_or_else(|| "step has no stored schema".to_string())?;
378    validate_payload(schema, payload)
379}
380
381#[cfg(test)]
382mod tests {
383    use chrono::Utc;
384    use schemars::schema_for;
385    use serde_json::to_value;
386
387    use super::*;
388
389    #[derive(Serialize, Deserialize, JsonSchema)]
390    struct PipelineFinished {
391        status: String,
392    }
393
394    fn schema() -> Value {
395        to_value(schema_for!(PipelineFinished)).unwrap()
396    }
397
398    #[test]
399    fn validate_payload_accepts_a_matching_payload() {
400        assert_eq!(
401            validate_payload(&schema(), &json!({"status": "success"})),
402            Ok(())
403        );
404    }
405
406    #[test]
407    fn validate_payload_rejects_a_mismatching_payload() {
408        let err = validate_payload(&schema(), &json!({"state": 1})).unwrap_err();
409        assert!(err.contains("status"), "got {err}");
410    }
411
412    #[test]
413    fn validate_payload_rejects_an_invalid_schema() {
414        let err = validate_payload(&json!({"type": 12}), &json!({})).unwrap_err();
415        assert!(err.contains("invalid payload schema"), "got {err}");
416    }
417
418    #[test]
419    fn validate_step_payload_requires_a_stored_schema() {
420        let err = validate_step_payload(None, &json!({})).unwrap_err();
421        assert_eq!(err, "step has no stored schema");
422
423        let input = json!({ SIGNAL_SCHEMA_KEY: schema() });
424        assert_eq!(
425            validate_step_payload(Some(&input), &json!({"status": "ok"})),
426            Ok(())
427        );
428    }
429
430    #[test]
431    fn signal_outputs_carry_the_timeout_flag() {
432        let signal = StoredSignal {
433            id: Uuid::now_v7(),
434            name: "ci.done".to_string(),
435            key: "abc".to_string(),
436            payload: json!({"status": "success"}),
437            idempotency_id: None,
438            received_at: Utc::now(),
439        };
440        let received = received_output(&signal);
441        assert_eq!(received[SIGNAL_TIMED_OUT_KEY], json!(false));
442        assert_eq!(received["payload"], signal.payload);
443        assert_eq!(received["signal_id"], json!(signal.id));
444        assert_eq!(timed_out_output(), json!({"timed_out": true}));
445    }
446}