1use anyhow::Result;
2use rusqlite::{params, Connection, OptionalExtension};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct CaptureDropInput<'a> {
6 pub host: Option<&'a str>,
7 pub session_id: Option<&'a str>,
8 pub project: Option<&'a str>,
9 pub tool_name: Option<&'a str>,
10 pub reason: &'a str,
11 pub detail: Option<&'a str>,
12 pub spill_path: Option<&'a str>,
13 pub recovered_event_id: Option<i64>,
14}
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct CaptureDropStats {
18 pub total: i64,
19 pub actionable: i64,
20 pub unrecovered_spills: i64,
21 pub latest_epoch: Option<i64>,
22 pub latest_reason: Option<String>,
23 pub latest_detail: Option<String>,
24}
25
26pub fn record_capture_drop(conn: &Connection, input: &CaptureDropInput<'_>) -> Result<i64> {
27 let now = chrono::Utc::now().timestamp();
28 let detail = input.detail.map(crate::db::capture::redact_capture_content);
29 conn.execute(
30 "INSERT INTO capture_drop_events
31 (host_id, session_id, project, tool_name, reason, detail, spill_path,
32 recovered_event_id, created_at_epoch, recovered_at_epoch)
33 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, CASE WHEN ?8 IS NULL THEN NULL ELSE ?9 END)",
34 params![
35 input.host,
36 input.session_id,
37 input.project,
38 input.tool_name,
39 input.reason,
40 detail,
41 input.spill_path,
42 input.recovered_event_id,
43 now,
44 ],
45 )?;
46 Ok(conn.last_insert_rowid())
47}
48
49pub fn mark_capture_spill_recovered(
50 conn: &Connection,
51 input: &CaptureDropInput<'_>,
52 recovered_event_id: i64,
53) -> Result<bool> {
54 let now = chrono::Utc::now().timestamp();
55 let updated = conn.execute(
56 "UPDATE capture_drop_events
57 SET recovered_event_id = ?7,
58 recovered_at_epoch = ?8
59 WHERE id = (
60 SELECT id FROM capture_drop_events
61 WHERE recovered_event_id IS NULL
62 AND reason = ?5
63 AND spill_path = ?6
64 AND ((host_id = ?1) OR (host_id IS NULL AND ?1 IS NULL))
65 AND ((session_id = ?2) OR (session_id IS NULL AND ?2 IS NULL))
66 AND ((project = ?3) OR (project IS NULL AND ?3 IS NULL))
67 AND ((tool_name = ?4) OR (tool_name IS NULL AND ?4 IS NULL))
68 ORDER BY created_at_epoch DESC, id DESC
69 LIMIT 1
70 )",
71 params![
72 input.host,
73 input.session_id,
74 input.project,
75 input.tool_name,
76 input.reason,
77 input.spill_path,
78 recovered_event_id,
79 now,
80 ],
81 )?;
82 Ok(updated > 0)
83}
84
85pub fn query_capture_drop_stats(conn: &Connection) -> Result<CaptureDropStats> {
86 if !capture_drop_table_exists(conn)? {
87 return Ok(CaptureDropStats::default());
88 }
89
90 let total = conn.query_row("SELECT COUNT(*) FROM capture_drop_events", [], |row| {
91 row.get(0)
92 })?;
93 let actionable = conn.query_row(
94 "SELECT COUNT(*)
95 FROM capture_drop_events
96 WHERE reason NOT IN ('adapter_skip', 'codex_bash_disabled', 'bash_read_only')
97 AND NOT (
98 reason IN ('db_open_failed', 'capture_persistence_failed')
99 AND recovered_event_id IS NOT NULL
100 )",
101 [],
102 |row| row.get(0),
103 )?;
104 let unrecovered_spills = conn.query_row(
105 "SELECT COUNT(*)
106 FROM capture_drop_events
107 WHERE reason IN ('db_open_failed', 'capture_persistence_failed')
108 AND recovered_event_id IS NULL",
109 [],
110 |row| row.get(0),
111 )?;
112 let latest = conn
113 .query_row(
114 "SELECT created_at_epoch, reason, detail
115 FROM capture_drop_events
116 ORDER BY created_at_epoch DESC, id DESC
117 LIMIT 1",
118 [],
119 |row| {
120 Ok((
121 row.get::<_, i64>(0)?,
122 row.get::<_, String>(1)?,
123 row.get::<_, Option<String>>(2)?,
124 ))
125 },
126 )
127 .optional()?;
128
129 Ok(match latest {
130 Some((latest_epoch, latest_reason, latest_detail)) => CaptureDropStats {
131 total,
132 actionable,
133 unrecovered_spills,
134 latest_epoch: Some(latest_epoch),
135 latest_reason: Some(latest_reason),
136 latest_detail,
137 },
138 None => CaptureDropStats {
139 total,
140 actionable,
141 unrecovered_spills,
142 ..CaptureDropStats::default()
143 },
144 })
145}
146
147fn capture_drop_table_exists(conn: &Connection) -> Result<bool> {
148 let count: i64 = conn.query_row(
149 "SELECT COUNT(*)
150 FROM sqlite_master
151 WHERE type = 'table' AND name = ?1",
152 ["capture_drop_events"],
153 |row| row.get(0),
154 )?;
155 Ok(count > 0)
156}
157
158#[cfg(test)]
159mod tests {
160 use rusqlite::Connection;
161
162 use super::{query_capture_drop_stats, record_capture_drop, CaptureDropInput};
163
164 #[test]
165 fn capture_drop_stats_default_when_table_missing() -> anyhow::Result<()> {
166 let conn = Connection::open_in_memory()?;
167
168 let stats = query_capture_drop_stats(&conn)?;
169
170 assert_eq!(stats.total, 0);
171 assert_eq!(stats.actionable, 0);
172 assert_eq!(stats.unrecovered_spills, 0);
173 assert_eq!(stats.latest_reason, None);
174 Ok(())
175 }
176
177 #[test]
178 fn capture_drop_stats_report_latest_and_unrecovered_spills() -> anyhow::Result<()> {
179 let conn = Connection::open_in_memory()?;
180 conn.execute_batch("CREATE TABLE captured_events (id INTEGER PRIMARY KEY);")?;
181 conn.execute_batch(include_str!("../migrations/v036_capture_drop_events.sql"))?;
182
183 record_capture_drop(
184 &conn,
185 &CaptureDropInput {
186 host: Some("codex-cli"),
187 session_id: Some("session-a"),
188 project: Some("/repo"),
189 tool_name: Some("Edit"),
190 reason: "db_open_failed",
191 detail: Some("database is locked"),
192 spill_path: Some("/tmp/spill.jsonl"),
193 recovered_event_id: None,
194 },
195 )?;
196 record_capture_drop(
197 &conn,
198 &CaptureDropInput {
199 host: Some("codex-cli"),
200 session_id: Some("session-b"),
201 project: Some("/repo"),
202 tool_name: Some("Read"),
203 reason: "adapter_skip",
204 detail: Some("read-only tool"),
205 spill_path: None,
206 recovered_event_id: None,
207 },
208 )?;
209
210 let stats = query_capture_drop_stats(&conn)?;
211
212 assert_eq!(stats.total, 2);
213 assert_eq!(stats.actionable, 1);
214 assert_eq!(stats.unrecovered_spills, 1);
215 assert_eq!(stats.latest_reason.as_deref(), Some("adapter_skip"));
216 assert_eq!(stats.latest_detail.as_deref(), Some("read-only tool"));
217 Ok(())
218 }
219
220 #[test]
221 fn capture_drop_stats_treat_recovered_persistence_spills_as_non_actionable(
222 ) -> anyhow::Result<()> {
223 let conn = Connection::open_in_memory()?;
224 conn.execute_batch("CREATE TABLE captured_events (id INTEGER PRIMARY KEY);")?;
225 conn.execute_batch(include_str!("../migrations/v036_capture_drop_events.sql"))?;
226 conn.execute("INSERT INTO captured_events (id) VALUES (42)", [])?;
227
228 record_capture_drop(
229 &conn,
230 &CaptureDropInput {
231 host: Some("codex-cli"),
232 session_id: Some("session-recovered"),
233 project: Some("/repo"),
234 tool_name: Some("Edit"),
235 reason: "capture_persistence_failed",
236 detail: Some("events insert failed"),
237 spill_path: Some("/tmp/spill.jsonl"),
238 recovered_event_id: Some(42),
239 },
240 )?;
241 record_capture_drop(
242 &conn,
243 &CaptureDropInput {
244 host: Some("codex-cli"),
245 session_id: Some("session-open"),
246 project: Some("/repo"),
247 tool_name: Some("Edit"),
248 reason: "capture_persistence_failed",
249 detail: Some("events still blocked"),
250 spill_path: Some("/tmp/spill.jsonl"),
251 recovered_event_id: None,
252 },
253 )?;
254
255 let stats = query_capture_drop_stats(&conn)?;
256
257 assert_eq!(stats.total, 2);
258 assert_eq!(stats.actionable, 1);
259 assert_eq!(stats.unrecovered_spills, 1);
260 Ok(())
261 }
262
263 #[test]
264 fn capture_drop_redacts_sensitive_detail() -> anyhow::Result<()> {
265 let conn = Connection::open_in_memory()?;
266 conn.execute_batch("CREATE TABLE captured_events (id INTEGER PRIMARY KEY);")?;
267 conn.execute_batch(include_str!("../migrations/v036_capture_drop_events.sql"))?;
268
269 record_capture_drop(
270 &conn,
271 &CaptureDropInput {
272 host: Some("codex-cli"),
273 session_id: Some("session-secret"),
274 project: Some("/repo"),
275 tool_name: Some("Bash"),
276 reason: "codex_bash_disabled",
277 detail: Some(
278 "curl -H 'Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456'",
279 ),
280 spill_path: None,
281 recovered_event_id: None,
282 },
283 )?;
284
285 let detail: String = conn.query_row(
286 "SELECT detail FROM capture_drop_events WHERE session_id = 'session-secret'",
287 [],
288 |row| row.get(0),
289 )?;
290 assert!(detail.contains("[REDACTED]"));
291 assert!(!detail.contains("ghp_abcdefghijklmnopqrstuvwxyz123456"));
292 Ok(())
293 }
294}