Skip to main content

microclaw_engine/
trust_report.rs

1//! Opt-in periodic trust report.
2//!
3//! A digest of what the agent actually did over the reporting window —
4//! task runs, completion-contract verdicts, token spend, guardrail
5//! interventions, delivery/recovery health — delivered to every control
6//! chat. Rendering is a pure function over a snapshot struct; the loop
7//! only gathers, formats, delivers, and records the last-sent marker in
8//! `db_meta` so cadence survives restarts. No LLM calls are involved.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use tracing::{info, warn};
14
15use crate::internal::channels::channel::deliver_and_store_bot_message;
16use crate::internal::storage::db::call_blocking;
17use crate::runtime::AppState;
18
19const LAST_SENT_META_KEY: &str = "trust_report_last_sent_at";
20
21/// Everything one report renders, gathered up front so formatting is pure.
22#[derive(Debug, Clone, Default)]
23pub struct TrustReportData {
24    pub window_days: u64,
25    pub task_runs: i64,
26    pub task_successes: i64,
27    pub contracts_verified: i64,
28    pub contracts_failed: i64,
29    pub llm_requests: i64,
30    pub tokens_total: i64,
31    pub egress_events: i64,
32    pub hook_events: i64,
33    pub alert_events: i64,
34    pub recovery_events: i64,
35    pub budget_refusals_since_start: u64,
36    pub outbox_pending: i64,
37    pub outbox_failed: i64,
38    pub dlq_pending: i64,
39    pub restarts_total: u64,
40}
41
42pub fn format_trust_report(d: &TrustReportData) -> String {
43    let mut lines = vec![format!(
44        "Trust report — last {} day(s)",
45        d.window_days.max(1)
46    )];
47    lines.push(format!(
48        "Tasks: {} run(s), {} succeeded",
49        d.task_runs, d.task_successes
50    ));
51    lines.push(if d.contracts_verified + d.contracts_failed == 0 {
52        "Contracts: no verdicts recorded".to_string()
53    } else {
54        format!(
55            "Contracts: {} verified, {} failed",
56            d.contracts_verified, d.contracts_failed
57        )
58    });
59    lines.push(format!(
60        "Tokens: {} across {} provider call(s)",
61        d.tokens_total, d.llm_requests
62    ));
63    lines.push(format!(
64        "Guardrail audit events: egress={} hooks={} alerts={}",
65        d.egress_events, d.hook_events, d.alert_events
66    ));
67    lines.push(format!(
68        "Reliability: {} recovery event(s), outbox pending={} failed={}, scheduler DLQ={}",
69        d.recovery_events, d.outbox_pending, d.outbox_failed, d.dlq_pending
70    ));
71    lines.push(format!(
72        "Since process start: {} budget refusal(s), {} supervised-loop restart(s)",
73        d.budget_refusals_since_start, d.restarts_total
74    ));
75    lines.join("\n")
76}
77
78async fn gather(state: &Arc<AppState>, window_days: u64) -> TrustReportData {
79    let since =
80        (chrono::Utc::now() - chrono::Duration::days(window_days.max(1) as i64)).to_rfc3339();
81    let db_side = call_blocking(state.db.clone(), move |db| {
82        let (task_runs, task_successes) = db.get_task_run_summary_since(Some(&since))?;
83        let (contracts_verified, contracts_failed) = db.contract_verdict_counts_since(&since)?;
84        let usage = db.get_llm_usage_summary_since(None, Some(&since))?;
85        let egress_events = db.count_audit_events_since("egress_policy", &since)?;
86        let hook_events = db.count_audit_events_since("hook", &since)?;
87        let alert_events = db.count_audit_events_since("alert", &since)?;
88        let recovery_events = db.count_audit_events_since("turn_recovery", &since)?;
89        let delivery = db.outbound_delivery_health()?;
90        let dlq_pending = db.count_scheduled_task_dlq(false)?;
91        Ok::<_, microclaw_core::error::MicroClawError>((
92            task_runs,
93            task_successes,
94            contracts_verified,
95            contracts_failed,
96            usage,
97            (egress_events, hook_events, alert_events, recovery_events),
98            delivery,
99            dlq_pending,
100        ))
101    })
102    .await;
103
104    let mut data = TrustReportData {
105        window_days,
106        budget_refusals_since_start: crate::alerts::budget_refusal_count(),
107        restarts_total: crate::supervision::restart_counts()
108            .into_iter()
109            .map(|(_, count)| count)
110            .sum(),
111        ..Default::default()
112    };
113    match db_side {
114        Ok((
115            task_runs,
116            task_successes,
117            contracts_verified,
118            contracts_failed,
119            usage,
120            (egress_events, hook_events, alert_events, recovery_events),
121            delivery,
122            dlq_pending,
123        )) => {
124            data.task_runs = task_runs;
125            data.task_successes = task_successes;
126            data.contracts_verified = contracts_verified;
127            data.contracts_failed = contracts_failed;
128            data.llm_requests = usage.requests;
129            data.tokens_total = usage.total_tokens;
130            data.egress_events = egress_events;
131            data.hook_events = hook_events;
132            data.alert_events = alert_events;
133            data.recovery_events = recovery_events;
134            data.outbox_pending = delivery.pending_chunks + delivery.retry_chunks;
135            data.outbox_failed = delivery.failed_chunks;
136            data.dlq_pending = dlq_pending;
137        }
138        Err(err) => warn!("trust report: failed to gather data: {err}"),
139    }
140    data
141}
142
143/// True when the persisted last-sent marker is at least `interval_days` old
144/// (or absent/unparseable — a fresh install reports on the first tick).
145pub fn report_due(
146    last_sent: Option<&str>,
147    interval_days: u64,
148    now: chrono::DateTime<chrono::Utc>,
149) -> bool {
150    match last_sent.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) {
151        Some(sent) => {
152            now.signed_duration_since(sent.with_timezone(&chrono::Utc))
153                >= chrono::Duration::days(interval_days.max(1) as i64)
154        }
155        None => true,
156    }
157}
158
159/// One cadence check: send the report to every control chat when due.
160/// Returns true when a report was delivered to at least one chat.
161pub async fn run_trust_report_once(state: &Arc<AppState>) -> bool {
162    let cfg = &state.config.trust_report;
163    let last_sent = call_blocking(state.db.clone(), |db| {
164        db.get_runtime_meta(LAST_SENT_META_KEY)
165    })
166    .await
167    .unwrap_or_default();
168    if !report_due(last_sent.as_deref(), cfg.interval_days, chrono::Utc::now()) {
169        return false;
170    }
171    let control_chats = state.config.control_chat_ids.clone();
172    if control_chats.is_empty() {
173        warn!("trust report: enabled but no control_chat_ids configured; skipping");
174        return false;
175    }
176    let message = format_trust_report(&gather(state, cfg.interval_days).await);
177    let mut delivered = false;
178    for chat_id in control_chats {
179        let channel = call_blocking(state.db.clone(), move |db| db.get_chat_channel(chat_id))
180            .await
181            .ok()
182            .flatten()
183            .unwrap_or_default();
184        let bot_username = state.config.bot_username_for_channel(&channel);
185        match deliver_and_store_bot_message(
186            &state.channel_registry,
187            state.db.clone(),
188            &bot_username,
189            chat_id,
190            &message,
191        )
192        .await
193        {
194            Ok(_) => delivered = true,
195            Err(e) => warn!("trust report: delivery failed for chat {chat_id}: {e}"),
196        }
197    }
198    if delivered {
199        let now = chrono::Utc::now().to_rfc3339();
200        let _ = call_blocking(state.db.clone(), move |db| {
201            db.set_runtime_meta(LAST_SENT_META_KEY, &now)
202        })
203        .await;
204    }
205    delivered
206}
207
208pub fn spawn_trust_report(state: Arc<AppState>) {
209    if !state.config.trust_report.enabled {
210        return;
211    }
212    crate::supervision::spawn_supervised("trust_report", move || {
213        let state = state.clone();
214        async move {
215            info!(
216                "trust report: loop started (every {} day(s), to {} control chat(s))",
217                state.config.trust_report.interval_days.max(1),
218                state.config.control_chat_ids.len()
219            );
220            let mut ticker = tokio::time::interval(Duration::from_secs(3600));
221            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
222            loop {
223                ticker.tick().await;
224                run_trust_report_once(&state).await;
225            }
226        }
227    });
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn report_due_handles_missing_and_recent_markers() {
236        let now = chrono::Utc::now();
237        assert!(report_due(None, 7, now));
238        assert!(report_due(Some("not a timestamp"), 7, now));
239
240        let recent = (now - chrono::Duration::days(2)).to_rfc3339();
241        assert!(!report_due(Some(&recent), 7, now));
242
243        let stale = (now - chrono::Duration::days(8)).to_rfc3339();
244        assert!(report_due(Some(&stale), 7, now));
245    }
246
247    #[test]
248    fn format_covers_every_section() {
249        let text = format_trust_report(&TrustReportData {
250            window_days: 7,
251            task_runs: 12,
252            task_successes: 11,
253            contracts_verified: 5,
254            contracts_failed: 1,
255            llm_requests: 89,
256            tokens_total: 1_234_567,
257            egress_events: 2,
258            recovery_events: 1,
259            dlq_pending: 3,
260            ..Default::default()
261        });
262        assert!(text.contains("last 7 day(s)"));
263        assert!(text.contains("12 run(s), 11 succeeded"));
264        assert!(text.contains("5 verified, 1 failed"));
265        assert!(text.contains("1234567 across 89"));
266        assert!(text.contains("egress=2"));
267        assert!(text.contains("DLQ=3"));
268    }
269}