1use 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#[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
143pub 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
159pub 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}