Skip to main content

enact_core/background/
trigger.rs

1//! Trigger System for Background Callables
2//!
3//! Triggers enable event-based callable invocation. A trigger watches for
4//! specific events and spawns background executions when conditions are met.
5//!
6//! ## Trigger Types
7//!
8//! - **Event**: Fired by execution/step events
9//! - **Schedule**: Fired by cron/interval schedules
10//! - **Webhook**: Fired by external webhooks
11//! - **Threshold**: Fired when metrics cross thresholds
12//! - **Lifecycle**: Fired by thread/user lifecycle events
13//!
14//! @see packages/enact-schemas/src/execution.schemas.ts
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use svix_ksuid::KsuidLike;
20
21use crate::kernel::ids::{ExecutionId, TenantId};
22
23/// TriggerId - Unique identifier for a trigger
24/// Format: trigger_[27-char KSUID]
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct TriggerId(String);
28
29impl TriggerId {
30    /// Create a new TriggerId
31    pub fn new() -> Self {
32        Self(format!("trigger_{}", svix_ksuid::Ksuid::new(None, None)))
33    }
34
35    /// Create from string (for deserialization)
36    pub fn from_string(s: String) -> Self {
37        Self(s)
38    }
39
40    /// Get the inner string
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46impl Default for TriggerId {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl std::fmt::Display for TriggerId {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.0)
55    }
56}
57
58/// TriggerType - What kind of trigger this is
59/// @see packages/enact-schemas/src/execution.schemas.ts - triggerTypeSchema
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TriggerType {
63    /// Fired by an event (e.g., execution.completed, step.failed)
64    Event,
65    /// Fired by a schedule (cron, interval)
66    Schedule,
67    /// Fired by an external webhook
68    Webhook,
69    /// Fired when a metric crosses a threshold
70    Threshold,
71    /// Fired manually by user
72    Manual,
73    /// Fired by lifecycle events (thread.created, user.signup)
74    Lifecycle,
75}
76
77/// TriggerStatus - Current status of a trigger
78/// @see packages/enact-schemas/src/execution.schemas.ts - triggerStatusSchema
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum TriggerStatus {
82    /// Trigger is active and will fire
83    #[default]
84    Active,
85    /// Trigger is paused (won't fire)
86    Paused,
87    /// Trigger is disabled (won't fire, hidden)
88    Disabled,
89    /// Trigger has fired (for one-shot triggers)
90    Fired,
91    /// Trigger has expired (past end_at)
92    Expired,
93}
94
95/// ThresholdOperator - Comparison operator for threshold triggers
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum ThresholdOperator {
99    Gt,
100    Gte,
101    Lt,
102    Lte,
103    Eq,
104    Neq,
105}
106
107/// TriggerCondition - When the trigger should fire
108/// @see packages/enact-schemas/src/execution.schemas.ts - triggerConditionSchema
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct TriggerCondition {
112    /// Event type to match (for event triggers)
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub event_type: Option<String>,
115
116    /// Pattern to match against event data
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub event_pattern: Option<HashMap<String, serde_json::Value>>,
119
120    /// Cron expression (for schedule triggers)
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub cron_expression: Option<String>,
123
124    /// Interval in seconds (for schedule triggers)
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub interval_seconds: Option<u64>,
127
128    /// Metric name (for threshold triggers)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub metric_name: Option<String>,
131
132    /// Threshold value (for threshold triggers)
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub threshold_value: Option<f64>,
135
136    /// Threshold operator
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub threshold_operator: Option<ThresholdOperator>,
139}
140
141/// RetryConfig - Configuration for retry behavior
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct RetryConfig {
145    /// Maximum number of retry attempts
146    #[serde(default = "default_max_attempts")]
147    pub max_attempts: u32,
148
149    /// Initial backoff in milliseconds
150    #[serde(default = "default_backoff_ms")]
151    pub backoff_ms: u64,
152
153    /// Backoff multiplier for exponential backoff
154    #[serde(default = "default_backoff_multiplier")]
155    pub backoff_multiplier: f64,
156}
157
158fn default_max_attempts() -> u32 {
159    3
160}
161
162fn default_backoff_ms() -> u64 {
163    1000
164}
165
166fn default_backoff_multiplier() -> f64 {
167    2.0
168}
169
170impl Default for RetryConfig {
171    fn default() -> Self {
172        Self {
173            max_attempts: default_max_attempts(),
174            backoff_ms: default_backoff_ms(),
175            backoff_multiplier: default_backoff_multiplier(),
176        }
177    }
178}
179
180/// TargetBindingConfig - Where to write the result
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct TargetBindingConfig {
184    /// Target type (where to write the result)
185    pub target_type: TargetBindingType,
186
187    /// Custom target path (for custom target type)
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub target_path: Option<String>,
190}
191
192/// TargetBindingType - What kind of target to bind to
193/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingTypeSchema
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TargetBindingType {
197    /// Set thread title
198    #[serde(rename = "thread.title")]
199    ThreadTitle,
200    /// Set thread summary
201    #[serde(rename = "thread.summary")]
202    ThreadSummary,
203    /// Set execution summary
204    #[serde(rename = "execution.summary")]
205    ExecutionSummary,
206    /// Add to message metadata
207    #[serde(rename = "message.metadata")]
208    MessageMetadata,
209    /// Create an artifact
210    #[serde(rename = "artifact.create")]
211    ArtifactCreate,
212    /// Write to memory
213    #[serde(rename = "memory.write")]
214    MemoryWrite,
215    /// Custom target (requires targetPath)
216    Custom,
217}
218
219/// TriggerAction - What to do when the trigger fires
220/// @see packages/enact-schemas/src/execution.schemas.ts - triggerActionSchema
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct TriggerAction {
224    /// Callable to invoke
225    pub callable_name: String,
226
227    /// Input to pass to the callable
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub input: Option<String>,
230
231    /// Context to pass
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub context: Option<HashMap<String, String>>,
234
235    /// System prompt override
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub system_prompt: Option<String>,
238
239    /// Target binding for the result
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub target_binding: Option<TargetBindingConfig>,
242
243    /// Execute in background (fire-and-forget)
244    #[serde(default = "default_background")]
245    pub background: bool,
246
247    /// Retry configuration
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub retry: Option<RetryConfig>,
250}
251
252fn default_background() -> bool {
253    true
254}
255
256/// Trigger - Event-based callable invocation definition
257/// @see packages/enact-schemas/src/execution.schemas.ts - triggerSchema
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct Trigger {
261    /// Unique trigger identifier
262    pub trigger_id: TriggerId,
263
264    /// Tenant that owns this trigger
265    pub tenant_id: TenantId,
266
267    /// Human-readable name
268    pub name: String,
269
270    /// Optional description
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub description: Option<String>,
273
274    /// Trigger type
275    #[serde(rename = "type")]
276    pub trigger_type: TriggerType,
277
278    /// Current status
279    #[serde(default)]
280    pub status: TriggerStatus,
281
282    /// Condition (when to fire)
283    pub condition: TriggerCondition,
284
285    /// Action (what to do when fired)
286    pub action: TriggerAction,
287
288    /// When to start listening
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub start_at: Option<DateTime<Utc>>,
291
292    /// When to stop (expire)
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub end_at: Option<DateTime<Utc>>,
295
296    /// Max times to fire (None = unlimited)
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub max_fires: Option<u32>,
299
300    /// Times fired so far
301    #[serde(default)]
302    pub fire_count: u32,
303
304    /// Cooldown in milliseconds (prevent rapid re-firing)
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub cooldown_ms: Option<u64>,
307
308    /// Last time this trigger fired
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub last_fired_at: Option<DateTime<Utc>>,
311
312    /// When the trigger was created
313    pub created_at: DateTime<Utc>,
314
315    /// When the trigger was last updated
316    pub updated_at: DateTime<Utc>,
317}
318
319impl Trigger {
320    /// Create a new trigger
321    pub fn new(
322        tenant_id: TenantId,
323        name: impl Into<String>,
324        trigger_type: TriggerType,
325        condition: TriggerCondition,
326        action: TriggerAction,
327    ) -> Self {
328        let now = Utc::now();
329        Self {
330            trigger_id: TriggerId::new(),
331            tenant_id,
332            name: name.into(),
333            description: None,
334            trigger_type,
335            status: TriggerStatus::Active,
336            condition,
337            action,
338            start_at: None,
339            end_at: None,
340            max_fires: None,
341            fire_count: 0,
342            cooldown_ms: None,
343            last_fired_at: None,
344            created_at: now,
345            updated_at: now,
346        }
347    }
348
349    /// Check if the trigger can fire right now
350    pub fn can_fire(&self) -> bool {
351        // Must be active
352        if self.status != TriggerStatus::Active {
353            return false;
354        }
355
356        let now = Utc::now();
357
358        // Check start_at
359        if let Some(start_at) = self.start_at {
360            if now < start_at {
361                return false;
362            }
363        }
364
365        // Check end_at
366        if let Some(end_at) = self.end_at {
367            if now > end_at {
368                return false;
369            }
370        }
371
372        // Check max_fires
373        if let Some(max_fires) = self.max_fires {
374            if self.fire_count >= max_fires {
375                return false;
376            }
377        }
378
379        // Check cooldown
380        if let (Some(cooldown_ms), Some(last_fired_at)) = (self.cooldown_ms, self.last_fired_at) {
381            let cooldown = chrono::Duration::milliseconds(cooldown_ms as i64);
382            if now < last_fired_at + cooldown {
383                return false;
384            }
385        }
386
387        true
388    }
389
390    /// Record that the trigger fired
391    pub fn record_fire(&mut self) {
392        self.fire_count += 1;
393        self.last_fired_at = Some(Utc::now());
394        self.updated_at = Utc::now();
395
396        // Check if we've hit max_fires
397        if let Some(max_fires) = self.max_fires {
398            if self.fire_count >= max_fires {
399                self.status = TriggerStatus::Fired;
400            }
401        }
402    }
403
404    /// Pause the trigger
405    pub fn pause(&mut self) {
406        self.status = TriggerStatus::Paused;
407        self.updated_at = Utc::now();
408    }
409
410    /// Resume the trigger
411    pub fn resume(&mut self) {
412        if self.status == TriggerStatus::Paused {
413            self.status = TriggerStatus::Active;
414            self.updated_at = Utc::now();
415        }
416    }
417
418    /// Disable the trigger
419    pub fn disable(&mut self) {
420        self.status = TriggerStatus::Disabled;
421        self.updated_at = Utc::now();
422    }
423}
424
425/// TriggerFiredEvent - Emitted when a trigger fires
426/// @see packages/enact-schemas/src/execution.schemas.ts - triggerFiredEventSchema
427#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct TriggerFiredEvent {
430    pub trigger_id: TriggerId,
431    pub trigger_name: String,
432    pub trigger_type: TriggerType,
433    pub execution_id: ExecutionId,
434    pub fired_at: DateTime<Utc>,
435    pub trigger_source: serde_json::Value,
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_trigger_id_generation() {
444        let id = TriggerId::new();
445        assert!(id.as_str().starts_with("trigger_"));
446        assert_eq!(id.as_str().len(), 35); // "trigger_" + 27 chars
447    }
448
449    #[test]
450    fn test_trigger_can_fire() {
451        let tenant_id = TenantId::new();
452        let condition = TriggerCondition {
453            event_type: Some("execution.completed".to_string()),
454            ..Default::default()
455        };
456        let action = TriggerAction {
457            callable_name: "summarizer".to_string(),
458            input: None,
459            context: None,
460            system_prompt: None,
461            target_binding: Some(TargetBindingConfig {
462                target_type: TargetBindingType::ThreadTitle,
463                target_path: None,
464            }),
465            background: true,
466            retry: None,
467        };
468
469        let mut trigger = Trigger::new(
470            tenant_id,
471            "Auto-title",
472            TriggerType::Event,
473            condition,
474            action,
475        );
476
477        assert!(trigger.can_fire());
478
479        // Record fire and check again
480        trigger.record_fire();
481        assert_eq!(trigger.fire_count, 1);
482        assert!(trigger.last_fired_at.is_some());
483    }
484
485    #[test]
486    fn test_trigger_max_fires() {
487        let tenant_id = TenantId::new();
488        let mut trigger = Trigger::new(
489            tenant_id,
490            "One-shot",
491            TriggerType::Event,
492            TriggerCondition::default(),
493            TriggerAction {
494                callable_name: "task".to_string(),
495                input: None,
496                context: None,
497                system_prompt: None,
498                target_binding: None,
499                background: true,
500                retry: None,
501            },
502        );
503
504        trigger.max_fires = Some(1);
505
506        assert!(trigger.can_fire());
507        trigger.record_fire();
508        assert!(!trigger.can_fire());
509        assert_eq!(trigger.status, TriggerStatus::Fired);
510    }
511}