Skip to main content

ai_crew_sync/
webhooks.rs

1//! Outgoing webhooks: forward team-visible bus events to Slack, Discord or a
2//! generic JSON endpoint, so humans see what the agents are doing without
3//! opening anything.
4//!
5//! Privacy rule: direct messages are NEVER forwarded, only channel messages
6//! and team-wide events (tasks, locks, notes). That guard lives in the
7//! database trigger that enqueues deliveries — the only place one can be
8//! created — rather than in each consumer.
9//!
10//! Delivery is an outbox, not a fan-out from the event stream. A trigger
11//! enqueues one row per (event, matching webhook) when the change commits,
12//! which happens once however many replicas are running; each replica then
13//! claims rows with `FOR UPDATE SKIP LOCKED`, the same primitive task claims
14//! use. The guarantee is **at-least-once** with bounded retries: a receiver
15//! that 500s or times out is retried with backoff, and a receiver that keeps
16//! failing is parked as `failed` for an operator to see, never silently
17//! dropped.
18
19use std::time::Duration;
20
21use sqlx::PgPool;
22use tokio_util::sync::CancellationToken;
23use uuid::Uuid;
24
25use crate::events::EventHub;
26
27/// Attempts before a delivery is parked as permanently failed.
28const MAX_ATTEMPTS: i32 = 6;
29/// How many deliveries one replica claims per round.
30const BATCH: i64 = 32;
31/// In-flight POSTs per replica. Bounded so one slow receiver cannot starve
32/// the others, and so a burst cannot open unbounded sockets.
33const CONCURRENCY: usize = 8;
34/// Backstop poll when no notification arrives — also what makes retries due.
35const POLL_INTERVAL: Duration = Duration::from_secs(5);
36/// How often retention runs, measured in elapsed time rather than in idle
37/// rounds — a busy dispatcher has more to prune, not less.
38const PRUNE_INTERVAL: Duration = Duration::from_secs(600);
39/// Successful deliveries are evidence for a while, then noise.
40const KEEP_SENT: Duration = Duration::from_secs(24 * 3600);
41/// Failures stay long enough for someone to notice and investigate.
42const KEEP_FAILED: Duration = Duration::from_secs(7 * 24 * 3600);
43
44#[derive(sqlx::FromRow)]
45struct Delivery {
46    id: i64,
47    url: String,
48    kind: String,
49    summary: String,
50    payload: serde_json::Value,
51    attempts: i32,
52}
53
54/// Claim a batch of due deliveries for this replica.
55///
56/// `FOR UPDATE SKIP LOCKED` is what makes N replicas safe: two dispatchers
57/// racing for the same row means one of them takes it and the other moves on,
58/// rather than both sending it.
59async fn claim(pool: &PgPool) -> Result<Vec<Delivery>, sqlx::Error> {
60    sqlx::query_as(
61        r#"
62        WITH due AS (
63            SELECT d.id
64            FROM webhook_deliveries d
65            WHERE d.status = 'pending' AND d.next_attempt_at <= now()
66            ORDER BY d.next_attempt_at, d.id
67            FOR UPDATE SKIP LOCKED
68            LIMIT $1
69        )
70        UPDATE webhook_deliveries d
71        SET attempts = d.attempts + 1,
72            -- Hold the row for the duration of the attempt: if this replica
73            -- dies mid-POST, the row becomes due again instead of vanishing.
74            next_attempt_at = now() + interval '60 seconds',
75            updated_at = now()
76        FROM due, webhooks w
77        WHERE d.id = due.id AND w.id = d.webhook_id
78        RETURNING d.id, w.url, w.kind, d.summary, d.payload, d.attempts
79        "#,
80    )
81    .bind(BATCH)
82    .fetch_all(pool)
83    .await
84}
85
86/// Slack and Discord want their own envelope; everything else gets the event.
87fn body_for(kind: &str, summary: &str, payload: &serde_json::Value) -> serde_json::Value {
88    match kind {
89        "slack" => serde_json::json!({ "text": summary }),
90        "discord" => serde_json::json!({ "content": summary }),
91        _ => payload.clone(),
92    }
93}
94
95async fn mark_sent(pool: &PgPool, id: i64) {
96    // Losing this UPDATE means a delivered webhook is sent again later. That
97    // is survivable — the guarantee is at-least-once — but an operator
98    // debugging duplicates needs to see it.
99    if let Err(e) = sqlx::query(
100        "UPDATE webhook_deliveries SET status = 'sent', last_error = NULL, updated_at = now()
101         WHERE id = $1",
102    )
103    .bind(id)
104    .execute(pool)
105    .await
106    {
107        tracing::warn!(delivery = id, error = %e, "could not record a successful delivery");
108    }
109}
110
111/// Record a failed attempt: schedule a retry with exponential backoff, or park
112/// the delivery once it has used its attempts.
113async fn mark_failed(pool: &PgPool, id: i64, attempts: i32, error: &str) {
114    // 2s, 4s, 8s … capped, so a receiver that is down for a minute is retried
115    // a handful of times rather than hammered.
116    let backoff = 2i64.saturating_pow(attempts.clamp(1, 8) as u32).min(600);
117    let terminal = attempts >= MAX_ATTEMPTS;
118    if terminal {
119        tracing::warn!(
120            delivery = id,
121            attempts,
122            error,
123            "webhook delivery permanently failed"
124        );
125    }
126    let recorded = sqlx::query(
127        r#"
128        UPDATE webhook_deliveries
129        SET status = CASE WHEN $2 THEN 'failed' ELSE 'pending' END,
130            next_attempt_at = now() + make_interval(secs => $3),
131            last_error = left($4, 500),
132            updated_at = now()
133        WHERE id = $1
134        "#,
135    )
136    .bind(id)
137    .bind(terminal)
138    .bind(backoff as f64)
139    .bind(error)
140    .execute(pool)
141    .await;
142    if let Err(e) = recorded {
143        // The row keeps the 60s hold from claim() and becomes due again, so
144        // nothing is lost — but without this line the retry schedule looks
145        // inexplicable.
146        tracing::warn!(delivery = id, error = %e, "could not record a failed delivery");
147    }
148}
149
150/// Send one batch, bounded to `CONCURRENCY` in-flight requests.
151async fn send_batch(pool: &PgPool, http: &reqwest::Client, batch: Vec<Delivery>) {
152    let mut queue = batch.into_iter();
153    let mut running = tokio::task::JoinSet::new();
154
155    loop {
156        while running.len() < CONCURRENCY {
157            let Some(d) = queue.next() else { break };
158            let http = http.clone();
159            let pool = pool.clone();
160            running.spawn(async move {
161                let body = body_for(&d.kind, &d.summary, &d.payload);
162                match http.post(&d.url).json(&body).send().await {
163                    Ok(resp) if resp.status().is_success() => {
164                        // Drain before dropping: hyper only reuses a
165                        // connection whose body was consumed, and a webhook
166                        // dispatcher talks to the same few hosts all day.
167                        let _ = resp.bytes().await;
168                        mark_sent(&pool, d.id).await;
169                    }
170                    Ok(resp) => {
171                        let status = resp.status();
172                        // 4xx other than 408/429 will not improve on retry;
173                        // spend the attempts on things that might.
174                        let permanent = status.is_client_error()
175                            && status != reqwest::StatusCode::REQUEST_TIMEOUT
176                            && status != reqwest::StatusCode::TOO_MANY_REQUESTS;
177                        let attempts = if permanent { MAX_ATTEMPTS } else { d.attempts };
178                        let _ = resp.bytes().await;
179                        mark_failed(&pool, d.id, attempts, &format!("HTTP {status}")).await;
180                    }
181                    Err(e) => mark_failed(&pool, d.id, d.attempts, &e.to_string()).await,
182                }
183            });
184        }
185        match running.join_next().await {
186            None => break,
187            Some(Ok(())) => {}
188            // A delivery task should never panic; if one does, the row stays
189            // pending and retries forever with no clue why. Say so.
190            Some(Err(e)) => tracing::error!(error = %e, "webhook delivery task failed"),
191        }
192    }
193}
194
195/// Drop deliveries nobody will look at again.
196async fn prune(pool: &PgPool) {
197    let _ = sqlx::query(
198        r#"
199        DELETE FROM webhook_deliveries
200        WHERE (status = 'sent'   AND updated_at < now() - make_interval(secs => $1))
201           OR (status = 'failed' AND updated_at < now() - make_interval(secs => $2))
202        "#,
203    )
204    .bind(KEEP_SENT.as_secs() as f64)
205    .bind(KEEP_FAILED.as_secs() as f64)
206    .execute(pool)
207    .await;
208}
209
210/// Claim, send and retire deliveries until cancelled.
211///
212/// Woken by the in-process event hub (any bus event may have enqueued work)
213/// and by the poll interval, which is also what makes a backed-off retry due.
214/// A missed notification therefore costs latency, never a delivery.
215pub async fn run_dispatcher(pool: PgPool, hub: EventHub, ct: CancellationToken) {
216    let http = match reqwest::Client::builder()
217        .timeout(Duration::from_secs(5))
218        .build()
219    {
220        Ok(c) => c,
221        Err(e) => {
222            tracing::error!(error = %e, "webhook dispatcher could not build HTTP client");
223            return;
224        }
225    };
226    let mut rx = hub.subscribe();
227
228    let mut last_prune = tokio::time::Instant::now();
229
230    loop {
231        // Retention runs on elapsed time, not on idle rounds. Counting rounds
232        // meant a dispatcher that always found work never pruned at all —
233        // exactly the deployment where the table grows fastest.
234        if last_prune.elapsed() >= PRUNE_INTERVAL {
235            last_prune = tokio::time::Instant::now();
236            prune(&pool).await;
237        }
238
239        match claim(&pool).await {
240            Ok(batch) if !batch.is_empty() => {
241                send_batch(&pool, &http, batch).await;
242                continue; // there may be more due right now
243            }
244            Ok(_) => {}
245            Err(e) => tracing::warn!(error = %e, "claiming webhook deliveries failed"),
246        }
247
248        // Idle: wait for an event or the poll interval, whichever comes first.
249        tokio::select! {
250            _ = ct.cancelled() => return,
251            _ = tokio::time::sleep(POLL_INTERVAL) => {}
252            recv = rx.recv() => {
253                if matches!(recv, Err(tokio::sync::broadcast::error::RecvError::Closed)) {
254                    return;
255                }
256                // Lagging is harmless now: the work is in the outbox, and the
257                // next claim finds it regardless of what the stream missed.
258            }
259        }
260    }
261}
262
263// -------------------------------------------------------------- admin CLI --
264
265pub async fn webhook_add(
266    pool: &PgPool,
267    team: &str,
268    url: &str,
269    kind: &str,
270    events: &str,
271    channel: Option<String>,
272) -> anyhow::Result<()> {
273    let kind = kind.trim().to_lowercase();
274    if !["slack", "discord", "generic"].contains(&kind.as_str()) {
275        anyhow::bail!("kind must be slack, discord or generic");
276    }
277    let events: Vec<String> = events
278        .split(',')
279        .map(|e| e.trim().to_lowercase())
280        .filter(|e| !e.is_empty())
281        .collect();
282    for e in &events {
283        if !["message", "task", "lock", "note"].contains(&e.as_str()) {
284            anyhow::bail!("unknown event kind '{e}' (valid: message, task, lock, note)");
285        }
286    }
287    if events.is_empty() {
288        anyhow::bail!("at least one event kind is required");
289    }
290
291    let team_id: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
292        .bind(team)
293        .fetch_optional(pool)
294        .await?;
295    let Some((team_id,)) = team_id else {
296        anyhow::bail!("no team with slug '{team}'");
297    };
298
299    let (id,): (Uuid,) = sqlx::query_as(
300        r#"
301        INSERT INTO webhooks (team_id, url, kind, events, channel_filter)
302        VALUES ($1, $2, $3, $4, $5)
303        RETURNING id
304        "#,
305    )
306    .bind(team_id)
307    .bind(url)
308    .bind(&kind)
309    .bind(&events)
310    .bind(channel.as_deref().map(str::to_lowercase))
311    .fetch_one(pool)
312    .await?;
313
314    println!(
315        "webhook {id} registered ({kind}, events: {})",
316        events.join(",")
317    );
318    Ok(())
319}
320
321pub async fn webhook_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
322    let rows: Vec<(Uuid, String, String, Vec<String>, Option<String>, bool)> = sqlx::query_as(
323        r#"
324        SELECT w.id, w.url, w.kind, w.events, w.channel_filter, w.enabled
325        FROM webhooks w JOIN teams t ON t.id = w.team_id
326        WHERE t.slug = $1
327        ORDER BY w.created_at
328        "#,
329    )
330    .bind(team)
331    .fetch_all(pool)
332    .await?;
333    if rows.is_empty() {
334        println!("(no webhooks for team '{team}')");
335    }
336    for (id, url, kind, events, channel, enabled) in rows {
337        let chan = channel.map(|c| format!(" #{c}")).unwrap_or_default();
338        let state = if enabled { "" } else { " [disabled]" };
339        println!("{id}  {kind:<8} {}{chan}{state}  {url}", events.join(","));
340    }
341    Ok(())
342}
343
344pub async fn webhook_remove(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
345    let res = sqlx::query("DELETE FROM webhooks WHERE id = $1")
346        .bind(id)
347        .execute(pool)
348        .await?;
349    if res.rows_affected() == 0 {
350        anyhow::bail!("no webhook with id {id}");
351    }
352    println!("webhook {id} removed");
353    Ok(())
354}