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}