use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::cloud::{CloudState, CredentialProvider};
const MAX_ALERTS_PER_SESSION: usize = 16;
const MAX_SESSIONS: usize = 1024;
const MIN_POLL_DELAY: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PendingAlert {
pub alert_id: String,
pub session_ref_id: String,
pub severity: String,
pub headline: String,
pub body: String,
pub source_id: String,
pub created_at: String,
#[serde(default)]
pub delivered: bool,
}
pub struct PendingAlerts {
inner: DashMap<String, VecDeque<PendingAlert>>,
lru: Mutex<VecDeque<String>>,
}
impl PendingAlerts {
pub fn new() -> Self {
Self {
inner: DashMap::new(),
lru: Mutex::new(VecDeque::with_capacity(MAX_SESSIONS)),
}
}
pub async fn push(&self, alert: PendingAlert) {
let session_id = alert.session_ref_id.clone();
{
let mut entry = self.inner.entry(session_id.clone()).or_default();
if entry.len() >= MAX_ALERTS_PER_SESSION {
entry.pop_front();
}
entry.push_back(alert);
}
let mut lru = self.lru.lock().await;
lru.retain(|s| s != &session_id);
lru.push_back(session_id);
while lru.len() > MAX_SESSIONS {
if let Some(evicted) = lru.pop_front() {
self.inner.remove(&evicted);
}
}
}
pub fn pop_for_session(&self, session_ref_id: &str) -> Option<PendingAlert> {
let mut entry = self.inner.get_mut(session_ref_id)?;
entry.iter_mut().find(|a| !a.delivered).map(|a| {
a.delivered = true;
a.clone()
})
}
pub fn ack(&self, alert_id: Option<&str>) -> usize {
let mut count = 0usize;
match alert_id {
None => {
for mut entry in self.inner.iter_mut() {
count += entry.len();
entry.clear();
}
}
Some(id) => {
for mut entry in self.inner.iter_mut() {
let before = entry.len();
entry.retain(|a| a.alert_id != id);
count += before - entry.len();
}
}
}
count
}
pub fn pending_count(&self) -> usize {
self.inner.iter().map(|e| e.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn snapshot(&self) -> Vec<PendingAlert> {
let mut out = Vec::new();
for entry in self.inner.iter() {
out.extend(entry.iter().cloned());
}
out
}
}
impl Default for PendingAlerts {
fn default() -> Self {
Self::new()
}
}
pub async fn run_long_poll(
pending: Arc<PendingAlerts>,
cloud_state: CloudState,
credentials: Arc<dyn CredentialProvider>,
api_url: String,
machine_id: String,
http_client: reqwest::Client,
) {
let url = format!("{}/api/v1/alerts/pending", api_url.trim_end_matches('/'));
loop {
let delay = std::cmp::max(cloud_state.next_health_delay(), MIN_POLL_DELAY);
tokio::time::sleep(delay).await;
let Some(token) = credentials.retrieve() else {
continue;
};
let resp = http_client
.get(&url)
.bearer_auth(token.expose_secret())
.header("X-OpenLatch-Machine-Id", &machine_id)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => match r.json::<Vec<PendingAlert>>().await {
Ok(alerts) => {
for alert in alerts {
crate::telemetry::capture_global(
crate::telemetry::Event::config_pending_alert_received(
&alert.severity,
"claude-code",
),
);
pending.push(alert).await;
}
}
Err(e) => {
tracing::debug!(
target: "alerts",
error = %e,
"pending-alerts response parse failed"
);
}
},
Ok(r) => {
tracing::debug!(
target: "alerts",
status = %r.status(),
"pending-alerts long-poll non-success"
);
}
Err(e) => {
tracing::debug!(
target: "alerts",
error = %e,
"pending-alerts long-poll failed"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn alert(session: &str, alert_id: &str, severity: &str) -> PendingAlert {
PendingAlert {
alert_id: alert_id.into(),
session_ref_id: session.into(),
severity: severity.into(),
headline: "Configuration alert".into(),
body: "An MCP server was added that needs review.".into(),
source_id: "claude-code:mcp:abc123".into(),
created_at: "2026-05-07T12:00:00Z".into(),
delivered: false,
}
}
#[tokio::test]
async fn push_and_pop_returns_undelivered_then_marks_delivered() {
let p = PendingAlerts::new();
p.push(alert("sess_a", "alert_1", "high")).await;
let got = p.pop_for_session("sess_a").unwrap();
assert_eq!(got.alert_id, "alert_1");
assert!(got.delivered);
assert!(p.pop_for_session("sess_a").is_none());
}
#[tokio::test]
async fn per_session_ring_buffer_drops_oldest() {
let p = PendingAlerts::new();
for i in 0..(MAX_ALERTS_PER_SESSION + 3) {
p.push(alert("sess_a", &format!("a_{i}"), "low")).await;
}
let count: usize = p
.snapshot()
.iter()
.filter(|a| a.session_ref_id == "sess_a")
.count();
assert_eq!(count, MAX_ALERTS_PER_SESSION);
let ids: Vec<String> = p.snapshot().into_iter().map(|a| a.alert_id).collect();
assert!(!ids.contains(&"a_0".to_string()));
assert!(ids.contains(&"a_3".to_string()));
}
#[tokio::test]
async fn ack_by_id_removes_only_that_alert() {
let p = PendingAlerts::new();
p.push(alert("sess_a", "a", "low")).await;
p.push(alert("sess_a", "b", "low")).await;
let removed = p.ack(Some("a"));
assert_eq!(removed, 1);
let remaining: Vec<String> = p.snapshot().into_iter().map(|x| x.alert_id).collect();
assert_eq!(remaining, vec!["b".to_string()]);
}
#[tokio::test]
async fn ack_all_clears_everything() {
let p = PendingAlerts::new();
p.push(alert("sess_a", "a", "low")).await;
p.push(alert("sess_b", "b", "low")).await;
let removed = p.ack(None);
assert_eq!(removed, 2);
assert_eq!(p.pending_count(), 0);
}
#[tokio::test]
async fn pending_count_sums_across_sessions() {
let p = PendingAlerts::new();
p.push(alert("sess_a", "a", "low")).await;
p.push(alert("sess_b", "b", "low")).await;
p.push(alert("sess_b", "c", "low")).await;
assert_eq!(p.pending_count(), 3);
}
}