Skip to main content

codex_state/runtime/
logs.rs

1use super::*;
2
3const LOG_RETENTION_DAYS: i64 = 10;
4
5impl StateRuntime {
6    pub async fn insert_log(&self, entry: &LogEntry) -> anyhow::Result<()> {
7        self.insert_logs(std::slice::from_ref(entry)).await
8    }
9
10    /// Insert a batch of log entries into the logs table.
11    pub async fn insert_logs(&self, entries: &[LogEntry]) -> anyhow::Result<()> {
12        if entries.is_empty() {
13            return Ok(());
14        }
15
16        let mut tx = self.logs_pool.begin().await?;
17        let mut builder = QueryBuilder::<Sqlite>::new(
18            "INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, thread_id, process_uuid, module_path, file, line, estimated_bytes) ",
19        );
20        builder.push_values(entries, |mut row, entry| {
21            let feedback_log_body = entry.feedback_log_body.as_ref().or(entry.message.as_ref());
22            // Keep about 10 MiB of reader-visible log content per partition.
23            // Both `query_logs` and `/feedback` read the persisted
24            // `feedback_log_body`, while `LogEntry.message` is only a write-time
25            // fallback for callers that still populate the old field.
26            let estimated_bytes = feedback_log_body.map_or(0, String::len) as i64
27                + entry.level.len() as i64
28                + entry.target.len() as i64
29                + entry.module_path.as_ref().map_or(0, String::len) as i64
30                + entry.file.as_ref().map_or(0, String::len) as i64;
31            row.push_bind(entry.ts)
32                .push_bind(entry.ts_nanos)
33                .push_bind(&entry.level)
34                .push_bind(&entry.target)
35                .push_bind(feedback_log_body)
36                .push_bind(&entry.thread_id)
37                .push_bind(&entry.process_uuid)
38                .push_bind(&entry.module_path)
39                .push_bind(&entry.file)
40                .push_bind(entry.line)
41                .push_bind(estimated_bytes);
42        });
43        builder.build().execute(&mut *tx).await?;
44        self.prune_logs_after_insert(entries, &mut tx).await?;
45        tx.commit().await?;
46        Ok(())
47    }
48
49    /// Enforce per-partition retained-log-content caps after a successful batch insert.
50    ///
51    /// We maintain two independent budgets:
52    /// - Thread logs: rows with `thread_id IS NOT NULL`, capped per `thread_id`.
53    /// - Threadless process logs: rows with `thread_id IS NULL` ("threadless"),
54    ///   capped per `process_uuid` (including `process_uuid IS NULL` as its own
55    ///   threadless partition).
56    ///
57    /// "Threadless" means the log row is not associated with any conversation
58    /// thread, so retention is keyed by process identity instead.
59    ///
60    /// This runs inside the same transaction as the insert so callers never
61    /// observe "inserted but not yet pruned" rows.
62    async fn prune_logs_after_insert(
63        &self,
64        entries: &[LogEntry],
65        tx: &mut SqliteConnection,
66    ) -> anyhow::Result<()> {
67        let thread_ids: BTreeSet<&str> = entries
68            .iter()
69            .filter_map(|entry| entry.thread_id.as_deref())
70            .collect();
71        if !thread_ids.is_empty() {
72            // Cheap precheck: only run the heavier window-function prune for
73            // threads that are currently above the cap.
74            let mut over_limit_threads_query =
75                QueryBuilder::<Sqlite>::new("SELECT thread_id FROM logs WHERE thread_id IN (");
76            {
77                let mut separated = over_limit_threads_query.separated(", ");
78                for thread_id in &thread_ids {
79                    separated.push_bind(*thread_id);
80                }
81            }
82            over_limit_threads_query.push(") GROUP BY thread_id HAVING SUM(");
83            over_limit_threads_query.push("estimated_bytes");
84            over_limit_threads_query.push(") > ");
85            over_limit_threads_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
86            over_limit_threads_query.push(" OR COUNT(*) > ");
87            over_limit_threads_query.push_bind(LOG_PARTITION_ROW_LIMIT);
88            let over_limit_thread_ids: Vec<String> = over_limit_threads_query
89                .build()
90                .fetch_all(&mut *tx)
91                .await?
92                .into_iter()
93                .map(|row| row.try_get("thread_id"))
94                .collect::<Result<_, _>>()?;
95            if !over_limit_thread_ids.is_empty() {
96                // Enforce a strict per-thread cap by deleting every row whose
97                // newest-first cumulative bytes exceed the partition budget.
98                let mut prune_threads = QueryBuilder::<Sqlite>::new(
99                    r#"
100DELETE FROM logs
101WHERE id IN (
102    SELECT id
103    FROM (
104        SELECT
105            id,
106            SUM(
107"#,
108                );
109                prune_threads.push("estimated_bytes");
110                prune_threads.push(
111                    r#"
112            ) OVER (
113                PARTITION BY thread_id
114                ORDER BY ts DESC, ts_nanos DESC, id DESC
115            ) AS cumulative_bytes,
116            ROW_NUMBER() OVER (
117                PARTITION BY thread_id
118                ORDER BY ts DESC, ts_nanos DESC, id DESC
119            ) AS row_number
120        FROM logs
121        WHERE thread_id IN (
122"#,
123                );
124                {
125                    let mut separated = prune_threads.separated(", ");
126                    for thread_id in &over_limit_thread_ids {
127                        separated.push_bind(thread_id);
128                    }
129                }
130                prune_threads.push(
131                    r#"
132        )
133    )
134    WHERE cumulative_bytes >
135"#,
136                );
137                prune_threads.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
138                prune_threads.push(" OR row_number > ");
139                prune_threads.push_bind(LOG_PARTITION_ROW_LIMIT);
140                prune_threads.push("\n)");
141                prune_threads.build().execute(&mut *tx).await?;
142            }
143        }
144
145        let threadless_process_uuids: BTreeSet<&str> = entries
146            .iter()
147            .filter(|entry| entry.thread_id.is_none())
148            .filter_map(|entry| entry.process_uuid.as_deref())
149            .collect();
150        let has_threadless_null_process_uuid = entries
151            .iter()
152            .any(|entry| entry.thread_id.is_none() && entry.process_uuid.is_none());
153        if !threadless_process_uuids.is_empty() {
154            // Threadless logs are budgeted separately per process UUID.
155            let mut over_limit_processes_query = QueryBuilder::<Sqlite>::new(
156                "SELECT process_uuid FROM logs WHERE thread_id IS NULL AND process_uuid IN (",
157            );
158            {
159                let mut separated = over_limit_processes_query.separated(", ");
160                for process_uuid in &threadless_process_uuids {
161                    separated.push_bind(*process_uuid);
162                }
163            }
164            over_limit_processes_query.push(") GROUP BY process_uuid HAVING SUM(");
165            over_limit_processes_query.push("estimated_bytes");
166            over_limit_processes_query.push(") > ");
167            over_limit_processes_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
168            over_limit_processes_query.push(" OR COUNT(*) > ");
169            over_limit_processes_query.push_bind(LOG_PARTITION_ROW_LIMIT);
170            let over_limit_process_uuids: Vec<String> = over_limit_processes_query
171                .build()
172                .fetch_all(&mut *tx)
173                .await?
174                .into_iter()
175                .map(|row| row.try_get("process_uuid"))
176                .collect::<Result<_, _>>()?;
177            if !over_limit_process_uuids.is_empty() {
178                // Same strict cap policy as thread pruning, but only for
179                // threadless rows in the affected process UUIDs.
180                let mut prune_threadless_process_logs = QueryBuilder::<Sqlite>::new(
181                    r#"
182DELETE FROM logs
183WHERE id IN (
184    SELECT id
185    FROM (
186        SELECT
187            id,
188            SUM(
189"#,
190                );
191                prune_threadless_process_logs.push("estimated_bytes");
192                prune_threadless_process_logs.push(
193                    r#"
194            ) OVER (
195                PARTITION BY process_uuid
196                ORDER BY ts DESC, ts_nanos DESC, id DESC
197            ) AS cumulative_bytes,
198            ROW_NUMBER() OVER (
199                PARTITION BY process_uuid
200                ORDER BY ts DESC, ts_nanos DESC, id DESC
201            ) AS row_number
202        FROM logs
203        WHERE thread_id IS NULL
204          AND process_uuid IN (
205"#,
206                );
207                {
208                    let mut separated = prune_threadless_process_logs.separated(", ");
209                    for process_uuid in &over_limit_process_uuids {
210                        separated.push_bind(process_uuid);
211                    }
212                }
213                prune_threadless_process_logs.push(
214                    r#"
215          )
216    )
217    WHERE cumulative_bytes >
218"#,
219                );
220                prune_threadless_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
221                prune_threadless_process_logs.push(" OR row_number > ");
222                prune_threadless_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT);
223                prune_threadless_process_logs.push("\n)");
224                prune_threadless_process_logs
225                    .build()
226                    .execute(&mut *tx)
227                    .await?;
228            }
229        }
230        if has_threadless_null_process_uuid {
231            // Rows without a process UUID still need a cap; treat NULL as its
232            // own threadless partition.
233            let mut null_process_usage_query = QueryBuilder::<Sqlite>::new("SELECT SUM(");
234            null_process_usage_query.push("estimated_bytes");
235            null_process_usage_query.push(
236                ") AS total_bytes, COUNT(*) AS row_count FROM logs WHERE thread_id IS NULL AND process_uuid IS NULL",
237            );
238            let null_process_usage = null_process_usage_query.build().fetch_one(&mut *tx).await?;
239            let total_null_process_bytes: Option<i64> =
240                null_process_usage.try_get("total_bytes")?;
241            let null_process_row_count: i64 = null_process_usage.try_get("row_count")?;
242
243            if total_null_process_bytes.unwrap_or(0) > LOG_PARTITION_SIZE_LIMIT_BYTES
244                || null_process_row_count > LOG_PARTITION_ROW_LIMIT
245            {
246                let mut prune_threadless_null_process_logs = QueryBuilder::<Sqlite>::new(
247                    r#"
248DELETE FROM logs
249WHERE id IN (
250    SELECT id
251    FROM (
252        SELECT
253            id,
254            SUM(
255"#,
256                );
257                prune_threadless_null_process_logs.push("estimated_bytes");
258                prune_threadless_null_process_logs.push(
259                    r#"
260            ) OVER (
261                PARTITION BY process_uuid
262                ORDER BY ts DESC, ts_nanos DESC, id DESC
263            ) AS cumulative_bytes,
264            ROW_NUMBER() OVER (
265                PARTITION BY process_uuid
266                ORDER BY ts DESC, ts_nanos DESC, id DESC
267            ) AS row_number
268        FROM logs
269        WHERE thread_id IS NULL
270          AND process_uuid IS NULL
271    )
272    WHERE cumulative_bytes >
273"#,
274                );
275                prune_threadless_null_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
276                prune_threadless_null_process_logs.push(" OR row_number > ");
277                prune_threadless_null_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT);
278                prune_threadless_null_process_logs.push("\n)");
279                prune_threadless_null_process_logs
280                    .build()
281                    .execute(&mut *tx)
282                    .await?;
283            }
284        }
285        Ok(())
286    }
287
288    pub(crate) async fn delete_logs_before(&self, cutoff_ts: i64) -> anyhow::Result<u64> {
289        let result = sqlx::query("DELETE FROM logs WHERE ts < ?")
290            .bind(cutoff_ts)
291            .execute(self.logs_pool.as_ref())
292            .await?;
293        Ok(result.rows_affected())
294    }
295
296    pub(crate) async fn run_logs_startup_maintenance(&self) -> anyhow::Result<()> {
297        let Some(cutoff) =
298            Utc::now().checked_sub_signed(chrono::Duration::days(LOG_RETENTION_DAYS))
299        else {
300            return Ok(());
301        };
302        self.delete_logs_before(cutoff.timestamp()).await?;
303        // Startup cleanup should not wait behind or block foreground work.
304        // PASSIVE checkpoints copy whatever is immediately available and skip
305        // frames that would require waiting on active readers or writers.
306        sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
307            .execute(self.logs_pool.as_ref())
308            .await?;
309        Ok(())
310    }
311
312    /// Query logs with optional filters.
313    pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result<Vec<LogRow>> {
314        let mut builder = QueryBuilder::<Sqlite>::new(
315            "SELECT id, ts, ts_nanos, level, target, feedback_log_body AS message, thread_id, process_uuid, file, line FROM logs WHERE 1 = 1",
316        );
317        push_log_filters(&mut builder, query);
318        if query.descending {
319            builder.push(" ORDER BY id DESC");
320        } else {
321            builder.push(" ORDER BY id ASC");
322        }
323        if let Some(limit) = query.limit {
324            builder.push(" LIMIT ").push_bind(limit as i64);
325        }
326
327        let rows = builder
328            .build_query_as::<LogRow>()
329            .fetch_all(self.logs_pool.as_ref())
330            .await?;
331        Ok(rows)
332    }
333
334    /// Query feedback logs for a set of threads, capped to the SQLite retention budget.
335    pub async fn query_feedback_logs_for_threads(
336        &self,
337        thread_ids: &[&str],
338    ) -> anyhow::Result<Vec<u8>> {
339        if thread_ids.is_empty() {
340            return Ok(Vec::new());
341        }
342
343        let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX);
344        // Bound the fetched rows in SQL first so over-retained partitions do not have to load
345        // every row into memory, then apply the exact whole-line byte cap after formatting.
346        let mut builder = QueryBuilder::<Sqlite>::new(
347            r#"
348WITH requested_threads(thread_id) AS (
349    VALUES
350            "#,
351        );
352        {
353            let mut separated = builder.separated(", ");
354            for thread_id in thread_ids {
355                separated
356                    .push("(")
357                    .push_bind_unseparated(*thread_id)
358                    .push_unseparated(")");
359            }
360        }
361        builder.push(
362            r#"
363),
364latest_processes AS (
365    SELECT (
366        SELECT process_uuid
367        FROM logs
368        WHERE logs.thread_id = requested_threads.thread_id AND process_uuid IS NOT NULL
369        ORDER BY ts DESC, ts_nanos DESC, id DESC
370        LIMIT 1
371    ) AS process_uuid
372    FROM requested_threads
373),
374feedback_logs AS (
375    SELECT ts, ts_nanos, level, feedback_log_body, estimated_bytes, id
376    FROM logs
377    WHERE feedback_log_body IS NOT NULL AND (
378        thread_id IN (SELECT thread_id FROM requested_threads)
379        OR (
380            thread_id IS NULL
381            AND process_uuid IN (
382                SELECT process_uuid
383                FROM latest_processes
384                WHERE process_uuid IS NOT NULL
385            )
386        )
387    )
388),
389bounded_feedback_logs AS (
390    SELECT
391        ts,
392        ts_nanos,
393        level,
394        feedback_log_body,
395        id,
396        SUM(estimated_bytes) OVER (
397            ORDER BY ts DESC, ts_nanos DESC, id DESC
398        ) AS cumulative_estimated_bytes
399    FROM feedback_logs
400)
401SELECT ts, ts_nanos, level, feedback_log_body
402FROM bounded_feedback_logs
403WHERE cumulative_estimated_bytes <=
404"#,
405        );
406        builder.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
407        builder.push(" ORDER BY ts DESC, ts_nanos DESC, id DESC");
408        let rows = builder
409            .build_query_as::<FeedbackLogRow>()
410            .fetch_all(self.logs_pool.as_ref())
411            .await?;
412
413        let mut lines = Vec::new();
414        let mut total_bytes = 0usize;
415        for row in rows {
416            let line =
417                format_feedback_log_line(row.ts, row.ts_nanos, &row.level, &row.feedback_log_body);
418            if total_bytes.saturating_add(line.len()) > max_bytes {
419                break;
420            }
421            total_bytes += line.len();
422            lines.push(line);
423        }
424
425        let mut ordered_bytes = Vec::with_capacity(total_bytes);
426        for line in lines.into_iter().rev() {
427            ordered_bytes.extend_from_slice(line.as_bytes());
428        }
429
430        Ok(ordered_bytes)
431    }
432
433    /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget.
434    pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result<Vec<u8>> {
435        self.query_feedback_logs_for_threads(&[thread_id]).await
436    }
437
438    /// Return the max log id matching optional filters.
439    pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result<i64> {
440        let mut builder =
441            QueryBuilder::<Sqlite>::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1");
442        push_log_filters(&mut builder, query);
443        let row = builder.build().fetch_one(self.logs_pool.as_ref()).await?;
444        let max_id: Option<i64> = row.try_get("max_id")?;
445        Ok(max_id.unwrap_or(0))
446    }
447}
448
449#[derive(sqlx::FromRow)]
450struct FeedbackLogRow {
451    ts: i64,
452    ts_nanos: i64,
453    level: String,
454    feedback_log_body: String,
455}
456
457fn format_feedback_log_line(
458    ts: i64,
459    ts_nanos: i64,
460    level: &str,
461    feedback_log_body: &str,
462) -> String {
463    let nanos = u32::try_from(ts_nanos).unwrap_or(0);
464    let timestamp = match DateTime::<Utc>::from_timestamp(ts, nanos) {
465        Some(dt) => dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true),
466        None => format!("{ts}.{ts_nanos:09}Z"),
467    };
468    let mut line = format!("{timestamp} {level:>5} {feedback_log_body}");
469    if !line.ends_with('\n') {
470        line.push('\n');
471    }
472    line
473}
474
475fn push_log_filters(builder: &mut QueryBuilder<Sqlite>, query: &LogQuery) {
476    if !query.levels_upper.is_empty() {
477        builder.push(" AND UPPER(level) IN (");
478        {
479            let mut separated = builder.separated(", ");
480            for level_upper in &query.levels_upper {
481                separated.push_bind(level_upper.as_str());
482            }
483        }
484        builder.push(")");
485    }
486    if let Some(from_ts) = query.from_ts {
487        builder.push(" AND ts >= ").push_bind(from_ts);
488    }
489    if let Some(to_ts) = query.to_ts {
490        builder.push(" AND ts <= ").push_bind(to_ts);
491    }
492    push_like_filters(builder, "module_path", &query.module_like);
493    push_like_filters(builder, "file", &query.file_like);
494    let has_thread_filter = !query.thread_ids.is_empty() || query.include_threadless;
495    if has_thread_filter {
496        builder.push(" AND (");
497        let mut needs_or = false;
498        for thread_id in &query.thread_ids {
499            if needs_or {
500                builder.push(" OR ");
501            }
502            builder.push("thread_id = ").push_bind(thread_id.as_str());
503            needs_or = true;
504        }
505        if query.include_threadless {
506            if needs_or {
507                builder.push(" OR ");
508            }
509            builder.push("thread_id IS NULL");
510        }
511        builder.push(")");
512    }
513    if let Some(after_id) = query.after_id {
514        builder.push(" AND id > ").push_bind(after_id);
515    }
516    if let Some(search) = query.search.as_ref() {
517        builder.push(" AND INSTR(COALESCE(feedback_log_body, ''), ");
518        builder.push_bind(search.as_str());
519        builder.push(") > 0");
520    }
521}
522
523fn push_like_filters(builder: &mut QueryBuilder<Sqlite>, column: &str, filters: &[String]) {
524    if filters.is_empty() {
525        return;
526    }
527    builder.push(" AND (");
528    for (idx, filter) in filters.iter().enumerate() {
529        if idx > 0 {
530            builder.push(" OR ");
531        }
532        builder
533            .push(column)
534            .push(" LIKE '%' || ")
535            .push_bind(filter.as_str())
536            .push(" || '%'");
537    }
538    builder.push(")");
539}
540
541#[cfg(test)]
542mod tests {
543    use super::StateRuntime;
544    use super::format_feedback_log_line;
545    use super::test_support::unique_temp_dir;
546    use crate::LogEntry;
547    use crate::LogQuery;
548    use crate::logs_db_path;
549    use crate::migrations::LOGS_MIGRATOR;
550    use chrono::Utc;
551    use codex_utils_absolute_path::test_support::PathExt;
552    use pretty_assertions::assert_eq;
553    use sqlx::SqlitePool;
554    use sqlx::migrate::Migrator;
555    use std::borrow::Cow;
556    use std::path::Path;
557
558    async fn open_db_pool(path: &Path) -> SqlitePool {
559        crate::SqliteConfig::new_for_testing(path.parent().unwrap_or(path).abs())
560            .open_read_write_pool(path)
561            .await
562            .expect("open sqlite pool")
563    }
564
565    async fn log_row_count(path: &Path) -> i64 {
566        let pool = open_db_pool(path).await;
567        let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs")
568            .fetch_one(&pool)
569            .await
570            .expect("count log rows");
571        pool.close().await;
572        count
573    }
574
575    #[tokio::test]
576    async fn insert_logs_use_dedicated_log_database() {
577        let codex_home = unique_temp_dir();
578        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
579            .await
580            .expect("initialize runtime");
581
582        runtime
583            .insert_logs(&[LogEntry {
584                ts: 1,
585                ts_nanos: 0,
586                level: "INFO".to_string(),
587                target: "cli".to_string(),
588                message: Some("dedicated-log-db".to_string()),
589                feedback_log_body: Some("dedicated-log-db".to_string()),
590                thread_id: Some("thread-1".to_string()),
591                process_uuid: Some("proc-1".to_string()),
592                module_path: Some("mod".to_string()),
593                file: Some("main.rs".to_string()),
594                line: Some(7),
595            }])
596            .await
597            .expect("insert test logs");
598
599        let logs_count = log_row_count(logs_db_path(codex_home.as_path()).as_path()).await;
600
601        assert_eq!(logs_count, 1);
602
603        let _ = tokio::fs::remove_dir_all(codex_home).await;
604    }
605
606    #[tokio::test]
607    async fn init_migrates_message_only_logs_db_to_feedback_log_body_schema() {
608        let codex_home = unique_temp_dir();
609        tokio::fs::create_dir_all(&codex_home)
610            .await
611            .expect("create codex home");
612        let logs_path = logs_db_path(codex_home.as_path());
613        let old_logs_migrator = Migrator {
614            migrations: Cow::Owned(vec![LOGS_MIGRATOR.migrations[0].clone()]),
615            ignore_missing: false,
616            locking: true,
617            no_tx: false,
618            table_name: LOGS_MIGRATOR.table_name.clone(),
619            create_schemas: LOGS_MIGRATOR.create_schemas.clone(),
620        };
621        let pool = crate::SqliteConfig::new_for_testing(codex_home.as_path().abs())
622            .open_read_write_pool(&logs_path)
623            .await
624            .expect("open old logs db");
625        old_logs_migrator
626            .run(&pool)
627            .await
628            .expect("apply old logs schema");
629        sqlx::query(
630            "INSERT INTO logs (ts, ts_nanos, level, target, message, module_path, file, line, thread_id, process_uuid, estimated_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
631        )
632        .bind(Utc::now().timestamp())
633        .bind(0_i64)
634        .bind("INFO")
635        .bind("cli")
636        .bind("legacy-body")
637        .bind("mod")
638        .bind("main.rs")
639        .bind(7_i64)
640        .bind("thread-1")
641        .bind("proc-1")
642        .bind(16_i64)
643        .execute(&pool)
644        .await
645        .expect("insert legacy log row");
646        pool.close().await;
647        drop(pool);
648
649        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
650            .await
651            .expect("initialize runtime");
652
653        let rows = runtime
654            .query_logs(&LogQuery::default())
655            .await
656            .expect("query migrated logs");
657        assert_eq!(rows.len(), 1);
658        assert_eq!(rows[0].message.as_deref(), Some("legacy-body"));
659
660        let migrated_pool = open_db_pool(logs_path.as_path()).await;
661        let columns = sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info('logs')")
662            .fetch_all(&migrated_pool)
663            .await
664            .expect("load migrated columns");
665        assert_eq!(
666            columns,
667            vec![
668                "id".to_string(),
669                "ts".to_string(),
670                "ts_nanos".to_string(),
671                "level".to_string(),
672                "target".to_string(),
673                "feedback_log_body".to_string(),
674                "module_path".to_string(),
675                "file".to_string(),
676                "line".to_string(),
677                "thread_id".to_string(),
678                "process_uuid".to_string(),
679                "estimated_bytes".to_string(),
680            ]
681        );
682        let indexes = sqlx::query_scalar::<_, String>(
683            "SELECT name FROM pragma_index_list('logs') ORDER BY name",
684        )
685        .fetch_all(&migrated_pool)
686        .await
687        .expect("load migrated indexes");
688        assert_eq!(
689            indexes,
690            vec![
691                "idx_logs_process_uuid_threadless_ts".to_string(),
692                "idx_logs_thread_id".to_string(),
693                "idx_logs_thread_id_ts".to_string(),
694                "idx_logs_ts".to_string(),
695            ]
696        );
697        migrated_pool.close().await;
698
699        let _ = tokio::fs::remove_dir_all(codex_home).await;
700    }
701
702    #[tokio::test]
703    async fn init_configures_logs_db_with_incremental_auto_vacuum() {
704        let codex_home = unique_temp_dir();
705        let _runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
706            .await
707            .expect("initialize runtime");
708
709        let pool = open_db_pool(logs_db_path(codex_home.as_path()).as_path()).await;
710        let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum")
711            .fetch_one(&pool)
712            .await
713            .expect("read auto_vacuum pragma");
714        assert_eq!(auto_vacuum, 2);
715        pool.close().await;
716
717        let _ = tokio::fs::remove_dir_all(codex_home).await;
718    }
719
720    #[test]
721    fn format_feedback_log_line_matches_feedback_formatter_shape() {
722        assert_eq!(
723            format_feedback_log_line(
724                /*ts*/ 1,
725                /*ts_nanos*/ 123_456_000,
726                "INFO",
727                "alpha"
728            ),
729            "1970-01-01T00:00:01.123456Z  INFO alpha\n"
730        );
731    }
732
733    #[test]
734    fn format_feedback_log_line_preserves_existing_trailing_newline() {
735        assert_eq!(
736            format_feedback_log_line(
737                /*ts*/ 1,
738                /*ts_nanos*/ 123_456_000,
739                "INFO",
740                "alpha\n"
741            ),
742            "1970-01-01T00:00:01.123456Z  INFO alpha\n"
743        );
744    }
745
746    #[tokio::test]
747    async fn query_logs_with_search_matches_rendered_body_substring() {
748        let codex_home = unique_temp_dir();
749        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
750            .await
751            .expect("initialize runtime");
752
753        runtime
754            .insert_logs(&[
755                LogEntry {
756                    ts: 1_700_000_001,
757                    ts_nanos: 0,
758                    level: "INFO".to_string(),
759                    target: "cli".to_string(),
760                    message: Some("alpha".to_string()),
761                    feedback_log_body: Some("foo=1 alpha".to_string()),
762                    thread_id: Some("thread-1".to_string()),
763                    process_uuid: None,
764                    file: Some("main.rs".to_string()),
765                    line: Some(42),
766                    module_path: None,
767                },
768                LogEntry {
769                    ts: 1_700_000_002,
770                    ts_nanos: 0,
771                    level: "INFO".to_string(),
772                    target: "cli".to_string(),
773                    message: Some("alphabet".to_string()),
774                    feedback_log_body: Some("foo=2 alphabet".to_string()),
775                    thread_id: Some("thread-1".to_string()),
776                    process_uuid: None,
777                    file: Some("main.rs".to_string()),
778                    line: Some(43),
779                    module_path: None,
780                },
781            ])
782            .await
783            .expect("insert test logs");
784
785        let rows = runtime
786            .query_logs(&LogQuery {
787                search: Some("foo=2".to_string()),
788                ..Default::default()
789            })
790            .await
791            .expect("query matching logs");
792
793        assert_eq!(rows.len(), 1);
794        assert_eq!(rows[0].message.as_deref(), Some("foo=2 alphabet"));
795
796        let _ = tokio::fs::remove_dir_all(codex_home).await;
797    }
798
799    #[tokio::test]
800    async fn query_logs_filters_level_set_without_rewriting_stored_level() {
801        let codex_home = unique_temp_dir();
802        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
803            .await
804            .expect("initialize runtime");
805
806        runtime
807            .insert_logs(&[
808                LogEntry {
809                    ts: 1,
810                    ts_nanos: 0,
811                    level: "TRACE".to_string(),
812                    target: "cli".to_string(),
813                    message: Some("trace-row".to_string()),
814                    feedback_log_body: Some("trace-row".to_string()),
815                    thread_id: None,
816                    process_uuid: None,
817                    file: Some("main.rs".to_string()),
818                    line: Some(1),
819                    module_path: None,
820                },
821                LogEntry {
822                    ts: 2,
823                    ts_nanos: 0,
824                    level: "INFO".to_string(),
825                    target: "cli".to_string(),
826                    message: Some("info-row".to_string()),
827                    feedback_log_body: Some("info-row".to_string()),
828                    thread_id: None,
829                    process_uuid: None,
830                    file: Some("main.rs".to_string()),
831                    line: Some(2),
832                    module_path: None,
833                },
834                LogEntry {
835                    ts: 3,
836                    ts_nanos: 0,
837                    level: "warn".to_string(),
838                    target: "cli".to_string(),
839                    message: Some("warn-row".to_string()),
840                    feedback_log_body: Some("warn-row".to_string()),
841                    thread_id: None,
842                    process_uuid: None,
843                    file: Some("main.rs".to_string()),
844                    line: Some(3),
845                    module_path: None,
846                },
847                LogEntry {
848                    ts: 4,
849                    ts_nanos: 0,
850                    level: "ERROR".to_string(),
851                    target: "cli".to_string(),
852                    message: Some("error-row".to_string()),
853                    feedback_log_body: Some("error-row".to_string()),
854                    thread_id: None,
855                    process_uuid: None,
856                    file: Some("main.rs".to_string()),
857                    line: Some(4),
858                    module_path: None,
859                },
860            ])
861            .await
862            .expect("insert test logs");
863
864        let rows = runtime
865            .query_logs(&LogQuery {
866                levels_upper: vec!["WARN".to_string(), "ERROR".to_string()],
867                ..Default::default()
868            })
869            .await
870            .expect("query matching logs");
871        let actual = rows
872            .iter()
873            .map(|row| (row.level.as_str(), row.message.as_deref()))
874            .collect::<Vec<_>>();
875
876        assert_eq!(
877            actual,
878            vec![("warn", Some("warn-row")), ("ERROR", Some("error-row"))]
879        );
880
881        let _ = tokio::fs::remove_dir_all(codex_home).await;
882    }
883
884    #[tokio::test]
885    async fn insert_logs_prunes_old_rows_when_thread_exceeds_size_limit() {
886        let codex_home = unique_temp_dir();
887        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
888            .await
889            .expect("initialize runtime");
890
891        let six_mebibytes = "a".repeat(6 * 1024 * 1024);
892        runtime
893            .insert_logs(&[
894                LogEntry {
895                    ts: 1,
896                    ts_nanos: 0,
897                    level: "INFO".to_string(),
898                    target: "cli".to_string(),
899                    message: Some("small".to_string()),
900                    feedback_log_body: Some(six_mebibytes.clone()),
901                    thread_id: Some("thread-1".to_string()),
902                    process_uuid: Some("proc-1".to_string()),
903                    file: Some("main.rs".to_string()),
904                    line: Some(1),
905                    module_path: Some("mod".to_string()),
906                },
907                LogEntry {
908                    ts: 2,
909                    ts_nanos: 0,
910                    level: "INFO".to_string(),
911                    target: "cli".to_string(),
912                    message: Some("small".to_string()),
913                    feedback_log_body: Some(six_mebibytes.clone()),
914                    thread_id: Some("thread-1".to_string()),
915                    process_uuid: Some("proc-1".to_string()),
916                    file: Some("main.rs".to_string()),
917                    line: Some(2),
918                    module_path: Some("mod".to_string()),
919                },
920            ])
921            .await
922            .expect("insert test logs");
923
924        let rows = runtime
925            .query_logs(&LogQuery {
926                thread_ids: vec!["thread-1".to_string()],
927                ..Default::default()
928            })
929            .await
930            .expect("query thread logs");
931
932        assert_eq!(rows.len(), 1);
933        assert_eq!(rows[0].ts, 2);
934
935        let _ = tokio::fs::remove_dir_all(codex_home).await;
936    }
937
938    #[tokio::test]
939    async fn insert_logs_prunes_single_thread_row_when_it_exceeds_size_limit() {
940        let codex_home = unique_temp_dir();
941        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
942            .await
943            .expect("initialize runtime");
944
945        let eleven_mebibytes = "d".repeat(11 * 1024 * 1024);
946        runtime
947            .insert_logs(&[LogEntry {
948                ts: 1,
949                ts_nanos: 0,
950                level: "INFO".to_string(),
951                target: "cli".to_string(),
952                message: Some("small".to_string()),
953                feedback_log_body: Some(eleven_mebibytes),
954                thread_id: Some("thread-oversized".to_string()),
955                process_uuid: Some("proc-1".to_string()),
956                file: Some("main.rs".to_string()),
957                line: Some(1),
958                module_path: Some("mod".to_string()),
959            }])
960            .await
961            .expect("insert test log");
962
963        let rows = runtime
964            .query_logs(&LogQuery {
965                thread_ids: vec!["thread-oversized".to_string()],
966                ..Default::default()
967            })
968            .await
969            .expect("query thread logs");
970
971        assert!(rows.is_empty());
972
973        let _ = tokio::fs::remove_dir_all(codex_home).await;
974    }
975
976    #[tokio::test]
977    async fn insert_logs_prunes_threadless_rows_per_process_uuid_only() {
978        let codex_home = unique_temp_dir();
979        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
980            .await
981            .expect("initialize runtime");
982
983        let six_mebibytes = "b".repeat(6 * 1024 * 1024);
984        runtime
985            .insert_logs(&[
986                LogEntry {
987                    ts: 1,
988                    ts_nanos: 0,
989                    level: "INFO".to_string(),
990                    target: "cli".to_string(),
991                    message: Some(six_mebibytes.clone()),
992                    feedback_log_body: None,
993                    thread_id: None,
994                    process_uuid: Some("proc-1".to_string()),
995                    file: Some("main.rs".to_string()),
996                    line: Some(1),
997                    module_path: Some("mod".to_string()),
998                },
999                LogEntry {
1000                    ts: 2,
1001                    ts_nanos: 0,
1002                    level: "INFO".to_string(),
1003                    target: "cli".to_string(),
1004                    message: Some(six_mebibytes.clone()),
1005                    feedback_log_body: None,
1006                    thread_id: None,
1007                    process_uuid: Some("proc-1".to_string()),
1008                    file: Some("main.rs".to_string()),
1009                    line: Some(2),
1010                    module_path: Some("mod".to_string()),
1011                },
1012                LogEntry {
1013                    ts: 3,
1014                    ts_nanos: 0,
1015                    level: "INFO".to_string(),
1016                    target: "cli".to_string(),
1017                    message: Some(six_mebibytes),
1018                    feedback_log_body: None,
1019                    thread_id: Some("thread-1".to_string()),
1020                    process_uuid: Some("proc-1".to_string()),
1021                    file: Some("main.rs".to_string()),
1022                    line: Some(3),
1023                    module_path: Some("mod".to_string()),
1024                },
1025            ])
1026            .await
1027            .expect("insert test logs");
1028
1029        let rows = runtime
1030            .query_logs(&LogQuery {
1031                thread_ids: vec!["thread-1".to_string()],
1032                include_threadless: true,
1033                ..Default::default()
1034            })
1035            .await
1036            .expect("query thread and threadless logs");
1037
1038        let mut timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
1039        timestamps.sort_unstable();
1040        assert_eq!(timestamps, vec![2, 3]);
1041
1042        let _ = tokio::fs::remove_dir_all(codex_home).await;
1043    }
1044
1045    #[tokio::test]
1046    async fn insert_logs_prunes_single_threadless_process_row_when_it_exceeds_size_limit() {
1047        let codex_home = unique_temp_dir();
1048        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1049            .await
1050            .expect("initialize runtime");
1051
1052        let eleven_mebibytes = "e".repeat(11 * 1024 * 1024);
1053        runtime
1054            .insert_logs(&[LogEntry {
1055                ts: 1,
1056                ts_nanos: 0,
1057                level: "INFO".to_string(),
1058                target: "cli".to_string(),
1059                message: Some("small".to_string()),
1060                feedback_log_body: Some(eleven_mebibytes),
1061                thread_id: None,
1062                process_uuid: Some("proc-oversized".to_string()),
1063                file: Some("main.rs".to_string()),
1064                line: Some(1),
1065                module_path: Some("mod".to_string()),
1066            }])
1067            .await
1068            .expect("insert test log");
1069
1070        let rows = runtime
1071            .query_logs(&LogQuery {
1072                include_threadless: true,
1073                ..Default::default()
1074            })
1075            .await
1076            .expect("query threadless logs");
1077
1078        assert!(rows.is_empty());
1079
1080        let _ = tokio::fs::remove_dir_all(codex_home).await;
1081    }
1082
1083    #[tokio::test]
1084    async fn insert_logs_prunes_threadless_rows_with_null_process_uuid() {
1085        let codex_home = unique_temp_dir();
1086        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1087            .await
1088            .expect("initialize runtime");
1089
1090        let six_mebibytes = "c".repeat(6 * 1024 * 1024);
1091        runtime
1092            .insert_logs(&[
1093                LogEntry {
1094                    ts: 1,
1095                    ts_nanos: 0,
1096                    level: "INFO".to_string(),
1097                    target: "cli".to_string(),
1098                    message: Some(six_mebibytes.clone()),
1099                    feedback_log_body: None,
1100                    thread_id: None,
1101                    process_uuid: None,
1102                    file: Some("main.rs".to_string()),
1103                    line: Some(1),
1104                    module_path: Some("mod".to_string()),
1105                },
1106                LogEntry {
1107                    ts: 2,
1108                    ts_nanos: 0,
1109                    level: "INFO".to_string(),
1110                    target: "cli".to_string(),
1111                    message: Some(six_mebibytes),
1112                    feedback_log_body: None,
1113                    thread_id: None,
1114                    process_uuid: None,
1115                    file: Some("main.rs".to_string()),
1116                    line: Some(2),
1117                    module_path: Some("mod".to_string()),
1118                },
1119                LogEntry {
1120                    ts: 3,
1121                    ts_nanos: 0,
1122                    level: "INFO".to_string(),
1123                    target: "cli".to_string(),
1124                    message: Some("small".to_string()),
1125                    feedback_log_body: None,
1126                    thread_id: None,
1127                    process_uuid: Some("proc-1".to_string()),
1128                    file: Some("main.rs".to_string()),
1129                    line: Some(3),
1130                    module_path: Some("mod".to_string()),
1131                },
1132            ])
1133            .await
1134            .expect("insert test logs");
1135
1136        let rows = runtime
1137            .query_logs(&LogQuery {
1138                include_threadless: true,
1139                ..Default::default()
1140            })
1141            .await
1142            .expect("query threadless logs");
1143
1144        let mut timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
1145        timestamps.sort_unstable();
1146        assert_eq!(timestamps, vec![2, 3]);
1147
1148        let _ = tokio::fs::remove_dir_all(codex_home).await;
1149    }
1150
1151    #[tokio::test]
1152    async fn insert_logs_prunes_single_threadless_null_process_row_when_it_exceeds_limit() {
1153        let codex_home = unique_temp_dir();
1154        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1155            .await
1156            .expect("initialize runtime");
1157
1158        let eleven_mebibytes = "f".repeat(11 * 1024 * 1024);
1159        runtime
1160            .insert_logs(&[LogEntry {
1161                ts: 1,
1162                ts_nanos: 0,
1163                level: "INFO".to_string(),
1164                target: "cli".to_string(),
1165                message: Some("small".to_string()),
1166                feedback_log_body: Some(eleven_mebibytes),
1167                thread_id: None,
1168                process_uuid: None,
1169                file: Some("main.rs".to_string()),
1170                line: Some(1),
1171                module_path: Some("mod".to_string()),
1172            }])
1173            .await
1174            .expect("insert test log");
1175
1176        let rows = runtime
1177            .query_logs(&LogQuery {
1178                include_threadless: true,
1179                ..Default::default()
1180            })
1181            .await
1182            .expect("query threadless logs");
1183
1184        assert!(rows.is_empty());
1185
1186        let _ = tokio::fs::remove_dir_all(codex_home).await;
1187    }
1188
1189    #[tokio::test]
1190    async fn insert_logs_prunes_old_rows_when_thread_exceeds_row_limit() {
1191        let codex_home = unique_temp_dir();
1192        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1193            .await
1194            .expect("initialize runtime");
1195
1196        let entries: Vec<LogEntry> = (1..=1_001)
1197            .map(|ts| LogEntry {
1198                ts,
1199                ts_nanos: 0,
1200                level: "INFO".to_string(),
1201                target: "cli".to_string(),
1202                message: Some(format!("thread-row-{ts}")),
1203                feedback_log_body: None,
1204                thread_id: Some("thread-row-limit".to_string()),
1205                process_uuid: Some("proc-1".to_string()),
1206                file: Some("main.rs".to_string()),
1207                line: Some(ts),
1208                module_path: Some("mod".to_string()),
1209            })
1210            .collect();
1211        runtime
1212            .insert_logs(&entries)
1213            .await
1214            .expect("insert test logs");
1215
1216        let rows = runtime
1217            .query_logs(&LogQuery {
1218                thread_ids: vec!["thread-row-limit".to_string()],
1219                ..Default::default()
1220            })
1221            .await
1222            .expect("query thread logs");
1223
1224        let timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
1225        assert_eq!(timestamps.len(), 1_000);
1226        assert_eq!(timestamps.first().copied(), Some(2));
1227        assert_eq!(timestamps.last().copied(), Some(1_001));
1228
1229        let _ = tokio::fs::remove_dir_all(codex_home).await;
1230    }
1231
1232    #[tokio::test]
1233    async fn insert_logs_prunes_old_threadless_rows_when_process_exceeds_row_limit() {
1234        let codex_home = unique_temp_dir();
1235        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1236            .await
1237            .expect("initialize runtime");
1238
1239        let entries: Vec<LogEntry> = (1..=1_001)
1240            .map(|ts| LogEntry {
1241                ts,
1242                ts_nanos: 0,
1243                level: "INFO".to_string(),
1244                target: "cli".to_string(),
1245                message: Some(format!("process-row-{ts}")),
1246                feedback_log_body: None,
1247                thread_id: None,
1248                process_uuid: Some("proc-row-limit".to_string()),
1249                file: Some("main.rs".to_string()),
1250                line: Some(ts),
1251                module_path: Some("mod".to_string()),
1252            })
1253            .collect();
1254        runtime
1255            .insert_logs(&entries)
1256            .await
1257            .expect("insert test logs");
1258
1259        let rows = runtime
1260            .query_logs(&LogQuery {
1261                include_threadless: true,
1262                ..Default::default()
1263            })
1264            .await
1265            .expect("query threadless logs");
1266
1267        let timestamps: Vec<i64> = rows
1268            .into_iter()
1269            .filter(|row| row.process_uuid.as_deref() == Some("proc-row-limit"))
1270            .map(|row| row.ts)
1271            .collect();
1272        assert_eq!(timestamps.len(), 1_000);
1273        assert_eq!(timestamps.first().copied(), Some(2));
1274        assert_eq!(timestamps.last().copied(), Some(1_001));
1275
1276        let _ = tokio::fs::remove_dir_all(codex_home).await;
1277    }
1278
1279    #[tokio::test]
1280    async fn insert_logs_prunes_old_threadless_null_process_rows_when_row_limit_exceeded() {
1281        let codex_home = unique_temp_dir();
1282        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1283            .await
1284            .expect("initialize runtime");
1285
1286        let entries: Vec<LogEntry> = (1..=1_001)
1287            .map(|ts| LogEntry {
1288                ts,
1289                ts_nanos: 0,
1290                level: "INFO".to_string(),
1291                target: "cli".to_string(),
1292                message: Some(format!("null-process-row-{ts}")),
1293                feedback_log_body: None,
1294                thread_id: None,
1295                process_uuid: None,
1296                file: Some("main.rs".to_string()),
1297                line: Some(ts),
1298                module_path: Some("mod".to_string()),
1299            })
1300            .collect();
1301        runtime
1302            .insert_logs(&entries)
1303            .await
1304            .expect("insert test logs");
1305
1306        let rows = runtime
1307            .query_logs(&LogQuery {
1308                include_threadless: true,
1309                ..Default::default()
1310            })
1311            .await
1312            .expect("query threadless logs");
1313
1314        let timestamps: Vec<i64> = rows
1315            .into_iter()
1316            .filter(|row| row.process_uuid.is_none())
1317            .map(|row| row.ts)
1318            .collect();
1319        assert_eq!(timestamps.len(), 1_000);
1320        assert_eq!(timestamps.first().copied(), Some(2));
1321        assert_eq!(timestamps.last().copied(), Some(1_001));
1322
1323        let _ = tokio::fs::remove_dir_all(codex_home).await;
1324    }
1325
1326    #[tokio::test]
1327    async fn query_feedback_logs_returns_newest_lines_within_limit_in_order() {
1328        let codex_home = unique_temp_dir();
1329        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1330            .await
1331            .expect("initialize runtime");
1332
1333        runtime
1334            .insert_logs(&[
1335                LogEntry {
1336                    ts: 1,
1337                    ts_nanos: 0,
1338                    level: "INFO".to_string(),
1339                    target: "cli".to_string(),
1340                    message: Some("alpha".to_string()),
1341                    feedback_log_body: None,
1342                    thread_id: Some("thread-1".to_string()),
1343                    process_uuid: Some("proc-1".to_string()),
1344                    file: None,
1345                    line: None,
1346                    module_path: None,
1347                },
1348                LogEntry {
1349                    ts: 2,
1350                    ts_nanos: 0,
1351                    level: "INFO".to_string(),
1352                    target: "cli".to_string(),
1353                    message: Some("bravo".to_string()),
1354                    feedback_log_body: None,
1355                    thread_id: Some("thread-1".to_string()),
1356                    process_uuid: Some("proc-1".to_string()),
1357                    file: None,
1358                    line: None,
1359                    module_path: None,
1360                },
1361                LogEntry {
1362                    ts: 3,
1363                    ts_nanos: 0,
1364                    level: "INFO".to_string(),
1365                    target: "cli".to_string(),
1366                    message: Some("charlie".to_string()),
1367                    feedback_log_body: None,
1368                    thread_id: Some("thread-1".to_string()),
1369                    process_uuid: Some("proc-1".to_string()),
1370                    file: None,
1371                    line: None,
1372                    module_path: None,
1373                },
1374            ])
1375            .await
1376            .expect("insert test logs");
1377
1378        let bytes = runtime
1379            .query_feedback_logs("thread-1")
1380            .await
1381            .expect("query feedback logs");
1382
1383        assert_eq!(
1384            String::from_utf8(bytes).expect("valid utf-8"),
1385            [
1386                format_feedback_log_line(/*ts*/ 1, /*ts_nanos*/ 0, "INFO", "alpha"),
1387                format_feedback_log_line(/*ts*/ 2, /*ts_nanos*/ 0, "INFO", "bravo"),
1388                format_feedback_log_line(/*ts*/ 3, /*ts_nanos*/ 0, "INFO", "charlie"),
1389            ]
1390            .concat()
1391        );
1392
1393        let _ = tokio::fs::remove_dir_all(codex_home).await;
1394    }
1395
1396    #[tokio::test]
1397    async fn query_feedback_logs_excludes_oversized_newest_row() {
1398        let codex_home = unique_temp_dir();
1399        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1400            .await
1401            .expect("initialize runtime");
1402        let eleven_mebibytes = "z".repeat(11 * 1024 * 1024);
1403
1404        runtime
1405            .insert_logs(&[
1406                LogEntry {
1407                    ts: 1,
1408                    ts_nanos: 0,
1409                    level: "INFO".to_string(),
1410                    target: "cli".to_string(),
1411                    message: Some("small".to_string()),
1412                    feedback_log_body: None,
1413                    thread_id: Some("thread-oversized".to_string()),
1414                    process_uuid: Some("proc-1".to_string()),
1415                    file: None,
1416                    line: None,
1417                    module_path: None,
1418                },
1419                LogEntry {
1420                    ts: 2,
1421                    ts_nanos: 0,
1422                    level: "INFO".to_string(),
1423                    target: "cli".to_string(),
1424                    message: Some(eleven_mebibytes),
1425                    feedback_log_body: None,
1426                    thread_id: Some("thread-oversized".to_string()),
1427                    process_uuid: Some("proc-1".to_string()),
1428                    file: None,
1429                    line: None,
1430                    module_path: None,
1431                },
1432            ])
1433            .await
1434            .expect("insert test logs");
1435
1436        let bytes = runtime
1437            .query_feedback_logs("thread-oversized")
1438            .await
1439            .expect("query feedback logs");
1440
1441        assert_eq!(bytes, Vec::<u8>::new());
1442
1443        let _ = tokio::fs::remove_dir_all(codex_home).await;
1444    }
1445
1446    #[tokio::test]
1447    async fn query_feedback_logs_includes_threadless_rows_from_same_process() {
1448        let codex_home = unique_temp_dir();
1449        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1450            .await
1451            .expect("initialize runtime");
1452
1453        runtime
1454            .insert_logs(&[
1455                LogEntry {
1456                    ts: 1,
1457                    ts_nanos: 0,
1458                    level: "INFO".to_string(),
1459                    target: "cli".to_string(),
1460                    message: Some("threadless-before".to_string()),
1461                    feedback_log_body: None,
1462                    thread_id: None,
1463                    process_uuid: Some("proc-1".to_string()),
1464                    file: None,
1465                    line: None,
1466                    module_path: None,
1467                },
1468                LogEntry {
1469                    ts: 2,
1470                    ts_nanos: 0,
1471                    level: "INFO".to_string(),
1472                    target: "cli".to_string(),
1473                    message: Some("thread-scoped".to_string()),
1474                    feedback_log_body: None,
1475                    thread_id: Some("thread-1".to_string()),
1476                    process_uuid: Some("proc-1".to_string()),
1477                    file: None,
1478                    line: None,
1479                    module_path: None,
1480                },
1481                LogEntry {
1482                    ts: 3,
1483                    ts_nanos: 0,
1484                    level: "INFO".to_string(),
1485                    target: "cli".to_string(),
1486                    message: Some("threadless-after".to_string()),
1487                    feedback_log_body: None,
1488                    thread_id: None,
1489                    process_uuid: Some("proc-1".to_string()),
1490                    file: None,
1491                    line: None,
1492                    module_path: None,
1493                },
1494                LogEntry {
1495                    ts: 4,
1496                    ts_nanos: 0,
1497                    level: "INFO".to_string(),
1498                    target: "cli".to_string(),
1499                    message: Some("other-process-threadless".to_string()),
1500                    feedback_log_body: None,
1501                    thread_id: None,
1502                    process_uuid: Some("proc-2".to_string()),
1503                    file: None,
1504                    line: None,
1505                    module_path: None,
1506                },
1507            ])
1508            .await
1509            .expect("insert test logs");
1510
1511        let bytes = runtime
1512            .query_feedback_logs("thread-1")
1513            .await
1514            .expect("query feedback logs");
1515
1516        assert_eq!(
1517            String::from_utf8(bytes).expect("valid utf-8"),
1518            [
1519                format_feedback_log_line(
1520                    /*ts*/ 1,
1521                    /*ts_nanos*/ 0,
1522                    "INFO",
1523                    "threadless-before"
1524                ),
1525                format_feedback_log_line(
1526                    /*ts*/ 2,
1527                    /*ts_nanos*/ 0,
1528                    "INFO",
1529                    "thread-scoped"
1530                ),
1531                format_feedback_log_line(
1532                    /*ts*/ 3,
1533                    /*ts_nanos*/ 0,
1534                    "INFO",
1535                    "threadless-after"
1536                ),
1537            ]
1538            .concat()
1539        );
1540
1541        let _ = tokio::fs::remove_dir_all(codex_home).await;
1542    }
1543
1544    #[tokio::test]
1545    async fn query_feedback_logs_excludes_threadless_rows_from_prior_processes() {
1546        let codex_home = unique_temp_dir();
1547        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1548            .await
1549            .expect("initialize runtime");
1550
1551        runtime
1552            .insert_logs(&[
1553                LogEntry {
1554                    ts: 1,
1555                    ts_nanos: 0,
1556                    level: "INFO".to_string(),
1557                    target: "cli".to_string(),
1558                    message: Some("old-process-threadless".to_string()),
1559                    feedback_log_body: None,
1560                    thread_id: None,
1561                    process_uuid: Some("proc-old".to_string()),
1562                    file: None,
1563                    line: None,
1564                    module_path: None,
1565                },
1566                LogEntry {
1567                    ts: 2,
1568                    ts_nanos: 0,
1569                    level: "INFO".to_string(),
1570                    target: "cli".to_string(),
1571                    message: Some("old-process-thread".to_string()),
1572                    feedback_log_body: None,
1573                    thread_id: Some("thread-1".to_string()),
1574                    process_uuid: Some("proc-old".to_string()),
1575                    file: None,
1576                    line: None,
1577                    module_path: None,
1578                },
1579                LogEntry {
1580                    ts: 3,
1581                    ts_nanos: 0,
1582                    level: "INFO".to_string(),
1583                    target: "cli".to_string(),
1584                    message: Some("new-process-thread".to_string()),
1585                    feedback_log_body: None,
1586                    thread_id: Some("thread-1".to_string()),
1587                    process_uuid: Some("proc-new".to_string()),
1588                    file: None,
1589                    line: None,
1590                    module_path: None,
1591                },
1592                LogEntry {
1593                    ts: 4,
1594                    ts_nanos: 0,
1595                    level: "INFO".to_string(),
1596                    target: "cli".to_string(),
1597                    message: Some("new-process-threadless".to_string()),
1598                    feedback_log_body: None,
1599                    thread_id: None,
1600                    process_uuid: Some("proc-new".to_string()),
1601                    file: None,
1602                    line: None,
1603                    module_path: None,
1604                },
1605            ])
1606            .await
1607            .expect("insert test logs");
1608
1609        let bytes = runtime
1610            .query_feedback_logs("thread-1")
1611            .await
1612            .expect("query feedback logs");
1613
1614        assert_eq!(
1615            String::from_utf8(bytes).expect("valid utf-8"),
1616            [
1617                format_feedback_log_line(
1618                    /*ts*/ 2,
1619                    /*ts_nanos*/ 0,
1620                    "INFO",
1621                    "old-process-thread"
1622                ),
1623                format_feedback_log_line(
1624                    /*ts*/ 3,
1625                    /*ts_nanos*/ 0,
1626                    "INFO",
1627                    "new-process-thread"
1628                ),
1629                format_feedback_log_line(
1630                    /*ts*/ 4,
1631                    /*ts_nanos*/ 0,
1632                    "INFO",
1633                    "new-process-threadless"
1634                ),
1635            ]
1636            .concat()
1637        );
1638
1639        let _ = tokio::fs::remove_dir_all(codex_home).await;
1640    }
1641
1642    #[tokio::test]
1643    async fn query_feedback_logs_keeps_newest_suffix_across_thread_and_threadless_logs() {
1644        let codex_home = unique_temp_dir();
1645        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1646            .await
1647            .expect("initialize runtime");
1648        let thread_marker = "thread-scoped-oldest";
1649        let threadless_older_marker = "threadless-older";
1650        let threadless_newer_marker = "threadless-newer";
1651        let five_mebibytes = format!("{threadless_older_marker} {}", "a".repeat(5 * 1024 * 1024));
1652        let four_and_half_mebibytes = format!(
1653            "{threadless_newer_marker} {}",
1654            "b".repeat((9 * 1024 * 1024) / 2)
1655        );
1656        let one_mebibyte = format!("{thread_marker} {}", "c".repeat(1024 * 1024));
1657
1658        runtime
1659            .insert_logs(&[
1660                LogEntry {
1661                    ts: 1,
1662                    ts_nanos: 0,
1663                    level: "INFO".to_string(),
1664                    target: "cli".to_string(),
1665                    message: Some(one_mebibyte.clone()),
1666                    feedback_log_body: None,
1667                    thread_id: Some("thread-1".to_string()),
1668                    process_uuid: Some("proc-1".to_string()),
1669                    file: None,
1670                    line: None,
1671                    module_path: None,
1672                },
1673                LogEntry {
1674                    ts: 2,
1675                    ts_nanos: 0,
1676                    level: "INFO".to_string(),
1677                    target: "cli".to_string(),
1678                    message: Some(five_mebibytes),
1679                    feedback_log_body: None,
1680                    thread_id: None,
1681                    process_uuid: Some("proc-1".to_string()),
1682                    file: None,
1683                    line: None,
1684                    module_path: None,
1685                },
1686                LogEntry {
1687                    ts: 3,
1688                    ts_nanos: 0,
1689                    level: "INFO".to_string(),
1690                    target: "cli".to_string(),
1691                    message: Some(four_and_half_mebibytes),
1692                    feedback_log_body: None,
1693                    thread_id: None,
1694                    process_uuid: Some("proc-1".to_string()),
1695                    file: None,
1696                    line: None,
1697                    module_path: None,
1698                },
1699            ])
1700            .await
1701            .expect("insert test logs");
1702
1703        let bytes = runtime
1704            .query_feedback_logs("thread-1")
1705            .await
1706            .expect("query feedback logs");
1707        let logs = String::from_utf8(bytes).expect("valid utf-8");
1708
1709        assert!(!logs.contains(thread_marker));
1710        assert!(logs.contains(threadless_older_marker));
1711        assert!(logs.contains(threadless_newer_marker));
1712        assert_eq!(logs.matches('\n').count(), 2);
1713
1714        let _ = tokio::fs::remove_dir_all(codex_home).await;
1715    }
1716
1717    #[tokio::test]
1718    async fn query_feedback_logs_for_threads_merges_requested_threads_and_threadless_rows() {
1719        let codex_home = unique_temp_dir();
1720        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1721            .await
1722            .expect("initialize runtime");
1723
1724        runtime
1725            .insert_logs(&[
1726                LogEntry {
1727                    ts: 1,
1728                    ts_nanos: 0,
1729                    level: "INFO".to_string(),
1730                    target: "cli".to_string(),
1731                    message: Some("thread-1".to_string()),
1732                    feedback_log_body: None,
1733                    thread_id: Some("thread-1".to_string()),
1734                    process_uuid: Some("proc-1".to_string()),
1735                    file: None,
1736                    line: None,
1737                    module_path: None,
1738                },
1739                LogEntry {
1740                    ts: 2,
1741                    ts_nanos: 0,
1742                    level: "INFO".to_string(),
1743                    target: "cli".to_string(),
1744                    message: Some("thread-2".to_string()),
1745                    feedback_log_body: None,
1746                    thread_id: Some("thread-2".to_string()),
1747                    process_uuid: Some("proc-2".to_string()),
1748                    file: None,
1749                    line: None,
1750                    module_path: None,
1751                },
1752                LogEntry {
1753                    ts: 3,
1754                    ts_nanos: 0,
1755                    level: "INFO".to_string(),
1756                    target: "cli".to_string(),
1757                    message: Some("threadless-proc-1".to_string()),
1758                    feedback_log_body: None,
1759                    thread_id: None,
1760                    process_uuid: Some("proc-1".to_string()),
1761                    file: None,
1762                    line: None,
1763                    module_path: None,
1764                },
1765                LogEntry {
1766                    ts: 4,
1767                    ts_nanos: 0,
1768                    level: "INFO".to_string(),
1769                    target: "cli".to_string(),
1770                    message: Some("threadless-proc-2".to_string()),
1771                    feedback_log_body: None,
1772                    thread_id: None,
1773                    process_uuid: Some("proc-2".to_string()),
1774                    file: None,
1775                    line: None,
1776                    module_path: None,
1777                },
1778                LogEntry {
1779                    ts: 5,
1780                    ts_nanos: 0,
1781                    level: "INFO".to_string(),
1782                    target: "cli".to_string(),
1783                    message: Some("thread-3".to_string()),
1784                    feedback_log_body: None,
1785                    thread_id: Some("thread-3".to_string()),
1786                    process_uuid: Some("proc-3".to_string()),
1787                    file: None,
1788                    line: None,
1789                    module_path: None,
1790                },
1791                LogEntry {
1792                    ts: 6,
1793                    ts_nanos: 0,
1794                    level: "INFO".to_string(),
1795                    target: "cli".to_string(),
1796                    message: Some("threadless-proc-3".to_string()),
1797                    feedback_log_body: None,
1798                    thread_id: None,
1799                    process_uuid: Some("proc-3".to_string()),
1800                    file: None,
1801                    line: None,
1802                    module_path: None,
1803                },
1804            ])
1805            .await
1806            .expect("insert test logs");
1807
1808        let bytes = runtime
1809            .query_feedback_logs_for_threads(&["thread-1", "thread-2"])
1810            .await
1811            .expect("query feedback logs");
1812
1813        assert_eq!(
1814            String::from_utf8(bytes).expect("valid utf-8"),
1815            [
1816                format_feedback_log_line(/*ts*/ 1, /*ts_nanos*/ 0, "INFO", "thread-1"),
1817                format_feedback_log_line(/*ts*/ 2, /*ts_nanos*/ 0, "INFO", "thread-2"),
1818                format_feedback_log_line(
1819                    /*ts*/ 3,
1820                    /*ts_nanos*/ 0,
1821                    "INFO",
1822                    "threadless-proc-1"
1823                ),
1824                format_feedback_log_line(
1825                    /*ts*/ 4,
1826                    /*ts_nanos*/ 0,
1827                    "INFO",
1828                    "threadless-proc-2"
1829                ),
1830            ]
1831            .concat()
1832        );
1833
1834        let _ = tokio::fs::remove_dir_all(codex_home).await;
1835    }
1836
1837    #[tokio::test]
1838    async fn query_feedback_logs_for_threads_returns_empty_for_empty_thread_list() {
1839        let codex_home = unique_temp_dir();
1840        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1841            .await
1842            .expect("initialize runtime");
1843
1844        let bytes = runtime
1845            .query_feedback_logs_for_threads(&[])
1846            .await
1847            .expect("query feedback logs");
1848
1849        assert_eq!(bytes, Vec::<u8>::new());
1850
1851        let _ = tokio::fs::remove_dir_all(codex_home).await;
1852    }
1853}