1use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use svix_ksuid::KsuidLike;
20
21use crate::kernel::ids::{ExecutionId, TenantId};
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct TriggerId(String);
28
29impl TriggerId {
30 pub fn new() -> Self {
32 Self(format!("trigger_{}", svix_ksuid::Ksuid::new(None, None)))
33 }
34
35 pub fn from_string(s: String) -> Self {
37 Self(s)
38 }
39
40 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TriggerType {
63 Event,
65 Schedule,
67 Webhook,
69 Threshold,
71 Manual,
73 Lifecycle,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum TriggerStatus {
82 #[default]
84 Active,
85 Paused,
87 Disabled,
89 Fired,
91 Expired,
93}
94
95#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct TriggerCondition {
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub event_type: Option<String>,
115
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub event_pattern: Option<HashMap<String, serde_json::Value>>,
119
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub cron_expression: Option<String>,
123
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub interval_seconds: Option<u64>,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub metric_name: Option<String>,
131
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub threshold_value: Option<f64>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub threshold_operator: Option<ThresholdOperator>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct RetryConfig {
145 #[serde(default = "default_max_attempts")]
147 pub max_attempts: u32,
148
149 #[serde(default = "default_backoff_ms")]
151 pub backoff_ms: u64,
152
153 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct TargetBindingConfig {
184 pub target_type: TargetBindingType,
186
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub target_path: Option<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TargetBindingType {
197 #[serde(rename = "thread.title")]
199 ThreadTitle,
200 #[serde(rename = "thread.summary")]
202 ThreadSummary,
203 #[serde(rename = "execution.summary")]
205 ExecutionSummary,
206 #[serde(rename = "message.metadata")]
208 MessageMetadata,
209 #[serde(rename = "artifact.create")]
211 ArtifactCreate,
212 #[serde(rename = "memory.write")]
214 MemoryWrite,
215 Custom,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct TriggerAction {
224 pub callable_name: String,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub input: Option<String>,
230
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub context: Option<HashMap<String, String>>,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub system_prompt: Option<String>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub target_binding: Option<TargetBindingConfig>,
242
243 #[serde(default = "default_background")]
245 pub background: bool,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub retry: Option<RetryConfig>,
250}
251
252fn default_background() -> bool {
253 true
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct Trigger {
261 pub trigger_id: TriggerId,
263
264 pub tenant_id: TenantId,
266
267 pub name: String,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub description: Option<String>,
273
274 #[serde(rename = "type")]
276 pub trigger_type: TriggerType,
277
278 #[serde(default)]
280 pub status: TriggerStatus,
281
282 pub condition: TriggerCondition,
284
285 pub action: TriggerAction,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub start_at: Option<DateTime<Utc>>,
291
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub end_at: Option<DateTime<Utc>>,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub max_fires: Option<u32>,
299
300 #[serde(default)]
302 pub fire_count: u32,
303
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub cooldown_ms: Option<u64>,
307
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub last_fired_at: Option<DateTime<Utc>>,
311
312 pub created_at: DateTime<Utc>,
314
315 pub updated_at: DateTime<Utc>,
317}
318
319impl Trigger {
320 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 pub fn can_fire(&self) -> bool {
351 if self.status != TriggerStatus::Active {
353 return false;
354 }
355
356 let now = Utc::now();
357
358 if let Some(start_at) = self.start_at {
360 if now < start_at {
361 return false;
362 }
363 }
364
365 if let Some(end_at) = self.end_at {
367 if now > end_at {
368 return false;
369 }
370 }
371
372 if let Some(max_fires) = self.max_fires {
374 if self.fire_count >= max_fires {
375 return false;
376 }
377 }
378
379 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 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 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 pub fn pause(&mut self) {
406 self.status = TriggerStatus::Paused;
407 self.updated_at = Utc::now();
408 }
409
410 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 pub fn disable(&mut self) {
420 self.status = TriggerStatus::Disabled;
421 self.updated_at = Utc::now();
422 }
423}
424
425#[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); }
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 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}