cloacina_workflow/trigger.rs
1/*
2 * Copyright 2026 Colliery Software
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Trigger types for workflow authoring.
18//!
19//! T-0552 (I-0102 follow-up) relocated the `Trigger` trait from `cloacina`
20//! (engine-only) into this leaf crate so packaged cdylibs can collect
21//! `TriggerEntry` inventory entries (which hold `Arc<dyn Trigger>`) at
22//! link time, and the unified `cloacina::package!()` shell macro can walk
23//! them at FFI call time. Engine paths re-export `cloacina_workflow::Trigger`.
24
25use crate::Context;
26use async_trait::async_trait;
27use std::collections::hash_map::DefaultHasher;
28use std::fmt;
29use std::hash::{Hash, Hasher};
30use std::time::Duration;
31
32/// Result of a trigger poll operation.
33#[derive(Debug)]
34pub enum TriggerResult {
35 /// Do not fire the workflow, continue polling on the next interval.
36 Skip,
37 /// Fire the workflow with an optional context.
38 Fire(Option<Context<serde_json::Value>>),
39}
40
41impl TriggerResult {
42 /// Returns true if this result indicates the workflow should fire.
43 pub fn should_fire(&self) -> bool {
44 matches!(self, TriggerResult::Fire(_))
45 }
46
47 /// Extracts the context if this is a Fire result.
48 pub fn into_context(self) -> Option<Context<serde_json::Value>> {
49 match self {
50 TriggerResult::Fire(ctx) => ctx,
51 TriggerResult::Skip => None,
52 }
53 }
54
55 /// Computes a hash of the context for deduplication purposes.
56 ///
57 /// If no context is provided, returns a constant hash. This allows
58 /// deduplication based on the specific trigger conditions.
59 pub fn context_hash(&self) -> String {
60 match self {
61 TriggerResult::Skip => "skip".to_string(),
62 TriggerResult::Fire(None) => "fire_no_context".to_string(),
63 TriggerResult::Fire(Some(ctx)) => {
64 let mut hasher = DefaultHasher::new();
65 if let Ok(serialized) = serde_json::to_string(ctx.data()) {
66 serialized.hash(&mut hasher);
67 }
68 format!("{:016x}", hasher.finish())
69 }
70 }
71 }
72}
73
74/// Errors that can occur during trigger polling.
75#[derive(Debug, thiserror::Error)]
76pub enum TriggerError {
77 /// Error during trigger polling
78 #[error("Trigger poll error: {message}")]
79 PollError { message: String },
80 /// Context creation error
81 #[error("Context error: {0}")]
82 ContextError(#[from] crate::error::ContextError),
83}
84
85/// Core trait for user-defined triggers.
86///
87/// Triggers are polling functions that determine when a workflow should
88/// execute. Each trigger has a name, poll interval, and a `poll()` method
89/// that returns whether the workflow should fire.
90#[async_trait]
91pub trait Trigger: Send + Sync + fmt::Debug {
92 /// Returns the unique name of this trigger.
93 fn name(&self) -> &str;
94
95 /// Returns how often this trigger should be polled.
96 fn poll_interval(&self) -> Duration;
97
98 /// Returns whether concurrent executions with the same context are
99 /// allowed. When `false`, if a workflow execution with the same context
100 /// hash is already running, the trigger will not fire again until it
101 /// completes.
102 fn allow_concurrent(&self) -> bool;
103
104 /// Polls the trigger condition and returns whether to fire the workflow.
105 ///
106 /// Called at the configured `poll_interval`. Returns `TriggerResult::Skip`
107 /// to continue polling, `TriggerResult::Fire(ctx)` to fire the workflow.
108 /// Errors are logged and polling continues on the next interval.
109 async fn poll(&self) -> Result<TriggerResult, TriggerError>;
110
111 /// Returns this trigger's cron expression, if any. Cron-shaped triggers
112 /// override this to return `Some(expr)`; their `poll_interval` is ignored
113 /// and the reconciler routes them to the cron scheduler instead of the
114 /// runtime trigger registry. Default `None` covers all custom-poll
115 /// triggers.
116 fn cron_expression(&self) -> Option<String> {
117 None
118 }
119}