Skip to main content

deepstrike_sdk/
signals.rs

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/// Feed signals from any external source (cron, webhook, queue).
37#[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
79/// Entry point for all external signals into the agent.
80///
81/// - Cron scheduling: fires [`ScheduledPrompt`]s at the right wall-clock time
82/// - Webhook ingestion: push any [`RuntimeSignal`] directly via [`ingest`](SignalGateway::ingest)
83/// - Subscribe pattern: callers receive signals via [`subscribe`](SignalGateway::subscribe)
84pub 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    /// Subscribe to all signals emitted by this gateway.
99    /// Returns a [`GatewayReceiver`] that implements [`SignalSource`].
100    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    /// Schedule a [`ScheduledPrompt`] to fire at its `run_at_ms`. Idempotent by goal+time.
110    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    /// Cancel a previously scheduled prompt.
135    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    /// Ingest a raw external signal (e.g. from a webhook handler).
143    pub fn ingest(&self, signal: RuntimeSignal) {
144        let _ = self.tx.send(signal);
145    }
146
147    /// Abort all pending scheduled tasks.
148    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
161/// A broadcast receiver from a [`SignalGateway`] that implements [`SignalSource`].
162pub 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}