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 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 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 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 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 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 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 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 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 sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
307 .execute(self.logs_pool.as_ref())
308 .await?;
309 Ok(())
310 }
311
312 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 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 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 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 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 1,
725 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 1,
738 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(1, 0, "INFO", "alpha"),
1387 format_feedback_log_line(2, 0, "INFO", "bravo"),
1388 format_feedback_log_line(3, 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 1,
1521 0,
1522 "INFO",
1523 "threadless-before"
1524 ),
1525 format_feedback_log_line(
1526 2,
1527 0,
1528 "INFO",
1529 "thread-scoped"
1530 ),
1531 format_feedback_log_line(
1532 3,
1533 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 2,
1619 0,
1620 "INFO",
1621 "old-process-thread"
1622 ),
1623 format_feedback_log_line(
1624 3,
1625 0,
1626 "INFO",
1627 "new-process-thread"
1628 ),
1629 format_feedback_log_line(
1630 4,
1631 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(1, 0, "INFO", "thread-1"),
1817 format_feedback_log_line(2, 0, "INFO", "thread-2"),
1818 format_feedback_log_line(
1819 3,
1820 0,
1821 "INFO",
1822 "threadless-proc-1"
1823 ),
1824 format_feedback_log_line(
1825 4,
1826 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}