1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Mutex;
5use tokio::sync::broadcast;
6use tokio::task::JoinHandle;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct RuntimeSignal {
10 pub source: String,
11 pub signal_type: String,
12 pub urgency: String,
13 pub payload: serde_json::Value,
14 pub dedupe_key: Option<String>,
15 pub recipient: Option<String>,
16 pub deadline_ms: Option<u64>,
17 pub coalesce_key: Option<String>,
18 pub coalesced_count: u32,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SignalDeliveryReceipt {
23 pub delivery_id: String,
24 pub lease_token: String,
25}
26
27#[derive(Debug, Clone)]
28pub struct SignalClaim {
29 pub delivery_id: String,
30 pub lease_token: String,
31 pub signal_id: String,
32 pub delivery_attempt: u32,
33 pub signal: RuntimeSignal,
34}
35
36#[async_trait]
38pub trait SignalSource: Send + Sync {
39 async fn claim_signal(&self) -> crate::Result<Option<SignalClaim>>;
40 async fn ack_signal(&self, receipt: &SignalDeliveryReceipt) -> crate::Result<bool>;
41 async fn nack_signal(&self, receipt: &SignalDeliveryReceipt) -> crate::Result<bool>;
42}
43
44#[derive(Debug, Clone)]
45pub struct ScheduledPrompt {
46 pub goal: String,
47 pub run_at_ms: u64,
48 pub criteria: Vec<String>,
49}
50
51impl ScheduledPrompt {
52 pub fn new(goal: impl Into<String>, run_at_ms: u64) -> Self {
53 Self {
54 goal: goal.into(),
55 run_at_ms,
56 criteria: Vec::new(),
57 }
58 }
59
60 pub fn to_signal(&self) -> RuntimeSignal {
61 RuntimeSignal {
62 source: "cron".into(),
63 signal_type: "job".into(),
64 urgency: "normal".into(),
65 payload: serde_json::json!({
66 "goal": self.goal,
67 "criteria": self.criteria,
68 "run_at_ms": self.run_at_ms,
69 }),
70 dedupe_key: Some(format!("cron:{}:{}", self.goal, self.run_at_ms)),
71 recipient: None,
72 deadline_ms: None,
73 coalesce_key: None,
74 coalesced_count: 1,
75 }
76 }
77}
78
79pub struct SignalGateway {
85 tx: broadcast::Sender<RuntimeSignal>,
86 tasks: Mutex<HashMap<String, JoinHandle<()>>>,
87}
88
89impl SignalGateway {
90 pub fn new() -> Self {
91 let (tx, _) = broadcast::channel(1024);
92 Self {
93 tx,
94 tasks: Mutex::new(HashMap::new()),
95 }
96 }
97
98 pub fn subscribe(&self) -> GatewayReceiver {
101 GatewayReceiver {
102 state: tokio::sync::Mutex::new(GatewayReceiverState {
103 rx: self.tx.subscribe(),
104 pending: None,
105 }),
106 }
107 }
108
109 pub fn schedule(&self, prompt: ScheduledPrompt) {
111 let key = format!("cron:{}:{}", prompt.goal, prompt.run_at_ms);
112 let mut guard = self.tasks.lock().unwrap();
113 if guard.contains_key(&key) {
114 return;
115 }
116
117 let tx = self.tx.clone();
118 let signal = prompt.to_signal();
119 let now_ms = std::time::SystemTime::now()
120 .duration_since(std::time::UNIX_EPOCH)
121 .unwrap_or_default()
122 .as_millis() as u64;
123 let delay_ms = prompt.run_at_ms.saturating_sub(now_ms);
124
125 let handle = tokio::spawn(async move {
126 if delay_ms > 0 {
127 tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
128 }
129 let _ = tx.send(signal);
130 });
131 guard.insert(key, handle);
132 }
133
134 pub fn cancel(&self, goal: &str, run_at_ms: u64) {
136 let key = format!("cron:{goal}:{run_at_ms}");
137 if let Some(h) = self.tasks.lock().unwrap().remove(&key) {
138 h.abort();
139 }
140 }
141
142 pub fn ingest(&self, signal: RuntimeSignal) {
144 let _ = self.tx.send(signal);
145 }
146
147 pub fn destroy(&self) {
149 for (_, h) in self.tasks.lock().unwrap().drain() {
150 h.abort();
151 }
152 }
153}
154
155impl Default for SignalGateway {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161pub struct GatewayReceiver {
163 state: tokio::sync::Mutex<GatewayReceiverState>,
164}
165
166struct PendingDelivery {
167 delivery_id: String,
168 signal_id: String,
169 delivery_attempt: u32,
170 lease_token: Option<String>,
171 signal: RuntimeSignal,
172}
173
174struct GatewayReceiverState {
175 rx: broadcast::Receiver<RuntimeSignal>,
176 pending: Option<PendingDelivery>,
177}
178
179#[async_trait]
180impl SignalSource for GatewayReceiver {
181 async fn claim_signal(&self) -> crate::Result<Option<SignalClaim>> {
182 let mut state = self.state.lock().await;
183 if state.pending.is_none() {
184 let signal = match state.rx.recv().await {
185 Ok(signal) => signal,
186 Err(broadcast::error::RecvError::Lagged(_))
187 | Err(broadcast::error::RecvError::Closed) => return Ok(None),
188 };
189 state.pending = Some(PendingDelivery {
190 delivery_id: uuid::Uuid::new_v4().to_string(),
191 signal_id: uuid::Uuid::new_v4().to_string(),
192 delivery_attempt: 0,
193 lease_token: None,
194 signal,
195 });
196 }
197 let pending = state
198 .pending
199 .as_mut()
200 .expect("pending delivery initialized");
201 if pending.lease_token.is_some() {
202 return Ok(None);
203 }
204 pending.delivery_attempt = pending.delivery_attempt.saturating_add(1);
205 let lease_token = uuid::Uuid::new_v4().to_string();
206 pending.lease_token = Some(lease_token.clone());
207 Ok(Some(SignalClaim {
208 delivery_id: pending.delivery_id.clone(),
209 lease_token,
210 signal_id: pending.signal_id.clone(),
211 delivery_attempt: pending.delivery_attempt,
212 signal: pending.signal.clone(),
213 }))
214 }
215
216 async fn ack_signal(&self, receipt: &SignalDeliveryReceipt) -> crate::Result<bool> {
217 let mut state = self.state.lock().await;
218 let matches = state.pending.as_ref().is_some_and(|pending| {
219 pending.delivery_id == receipt.delivery_id
220 && pending.lease_token.as_deref() == Some(receipt.lease_token.as_str())
221 });
222 if matches {
223 state.pending = None;
224 }
225 Ok(matches)
226 }
227
228 async fn nack_signal(&self, receipt: &SignalDeliveryReceipt) -> crate::Result<bool> {
229 let mut state = self.state.lock().await;
230 let Some(pending) = state.pending.as_mut() else {
231 return Ok(false);
232 };
233 if pending.delivery_id != receipt.delivery_id
234 || pending.lease_token.as_deref() != Some(receipt.lease_token.as_str())
235 {
236 return Ok(false);
237 }
238 pending.lease_token = None;
239 Ok(true)
240 }
241}