Skip to main content

remem/db/
capture_drop.rs

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}