use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use svix_ksuid::KsuidLike;
use crate::kernel::ids::{ExecutionId, TenantId};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TriggerId(String);
impl TriggerId {
pub fn new() -> Self {
Self(format!("trigger_{}", svix_ksuid::Ksuid::new(None, None)))
}
pub fn from_string(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for TriggerId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for TriggerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerType {
Event,
Schedule,
Webhook,
Threshold,
Manual,
Lifecycle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TriggerStatus {
#[default]
Active,
Paused,
Disabled,
Fired,
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThresholdOperator {
Gt,
Gte,
Lt,
Lte,
Eq,
Neq,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerCondition {
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_pattern: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cron_expression: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metric_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold_value: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold_operator: Option<ThresholdOperator>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetryConfig {
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_backoff_ms")]
pub backoff_ms: u64,
#[serde(default = "default_backoff_multiplier")]
pub backoff_multiplier: f64,
}
fn default_max_attempts() -> u32 {
3
}
fn default_backoff_ms() -> u64 {
1000
}
fn default_backoff_multiplier() -> f64 {
2.0
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: default_max_attempts(),
backoff_ms: default_backoff_ms(),
backoff_multiplier: default_backoff_multiplier(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetBindingConfig {
pub target_type: TargetBindingType,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetBindingType {
#[serde(rename = "thread.title")]
ThreadTitle,
#[serde(rename = "thread.summary")]
ThreadSummary,
#[serde(rename = "execution.summary")]
ExecutionSummary,
#[serde(rename = "message.metadata")]
MessageMetadata,
#[serde(rename = "artifact.create")]
ArtifactCreate,
#[serde(rename = "memory.write")]
MemoryWrite,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerAction {
pub callable_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_binding: Option<TargetBindingConfig>,
#[serde(default = "default_background")]
pub background: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
}
fn default_background() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Trigger {
pub trigger_id: TriggerId,
pub tenant_id: TenantId,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "type")]
pub trigger_type: TriggerType,
#[serde(default)]
pub status: TriggerStatus,
pub condition: TriggerCondition,
pub action: TriggerAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_fires: Option<u32>,
#[serde(default)]
pub fire_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooldown_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_fired_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Trigger {
pub fn new(
tenant_id: TenantId,
name: impl Into<String>,
trigger_type: TriggerType,
condition: TriggerCondition,
action: TriggerAction,
) -> Self {
let now = Utc::now();
Self {
trigger_id: TriggerId::new(),
tenant_id,
name: name.into(),
description: None,
trigger_type,
status: TriggerStatus::Active,
condition,
action,
start_at: None,
end_at: None,
max_fires: None,
fire_count: 0,
cooldown_ms: None,
last_fired_at: None,
created_at: now,
updated_at: now,
}
}
pub fn can_fire(&self) -> bool {
if self.status != TriggerStatus::Active {
return false;
}
let now = Utc::now();
if let Some(start_at) = self.start_at {
if now < start_at {
return false;
}
}
if let Some(end_at) = self.end_at {
if now > end_at {
return false;
}
}
if let Some(max_fires) = self.max_fires {
if self.fire_count >= max_fires {
return false;
}
}
if let (Some(cooldown_ms), Some(last_fired_at)) = (self.cooldown_ms, self.last_fired_at) {
let cooldown = chrono::Duration::milliseconds(cooldown_ms as i64);
if now < last_fired_at + cooldown {
return false;
}
}
true
}
pub fn record_fire(&mut self) {
self.fire_count += 1;
self.last_fired_at = Some(Utc::now());
self.updated_at = Utc::now();
if let Some(max_fires) = self.max_fires {
if self.fire_count >= max_fires {
self.status = TriggerStatus::Fired;
}
}
}
pub fn pause(&mut self) {
self.status = TriggerStatus::Paused;
self.updated_at = Utc::now();
}
pub fn resume(&mut self) {
if self.status == TriggerStatus::Paused {
self.status = TriggerStatus::Active;
self.updated_at = Utc::now();
}
}
pub fn disable(&mut self) {
self.status = TriggerStatus::Disabled;
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerFiredEvent {
pub trigger_id: TriggerId,
pub trigger_name: String,
pub trigger_type: TriggerType,
pub execution_id: ExecutionId,
pub fired_at: DateTime<Utc>,
pub trigger_source: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trigger_id_generation() {
let id = TriggerId::new();
assert!(id.as_str().starts_with("trigger_"));
assert_eq!(id.as_str().len(), 35); }
#[test]
fn test_trigger_can_fire() {
let tenant_id = TenantId::new();
let condition = TriggerCondition {
event_type: Some("execution.completed".to_string()),
..Default::default()
};
let action = TriggerAction {
callable_name: "summarizer".to_string(),
input: None,
context: None,
system_prompt: None,
target_binding: Some(TargetBindingConfig {
target_type: TargetBindingType::ThreadTitle,
target_path: None,
}),
background: true,
retry: None,
};
let mut trigger = Trigger::new(
tenant_id,
"Auto-title",
TriggerType::Event,
condition,
action,
);
assert!(trigger.can_fire());
trigger.record_fire();
assert_eq!(trigger.fire_count, 1);
assert!(trigger.last_fired_at.is_some());
}
#[test]
fn test_trigger_max_fires() {
let tenant_id = TenantId::new();
let mut trigger = Trigger::new(
tenant_id,
"One-shot",
TriggerType::Event,
TriggerCondition::default(),
TriggerAction {
callable_name: "task".to_string(),
input: None,
context: None,
system_prompt: None,
target_binding: None,
background: true,
retry: None,
},
);
trigger.max_fires = Some(1);
assert!(trigger.can_fire());
trigger.record_fire();
assert!(!trigger.can_fire());
assert_eq!(trigger.status, TriggerStatus::Fired);
}
}