mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## What changed - Store `logs_2.sqlite` in `log_dir` when explicitly configured, unless a managed `sqlite_home` requirement applies. Keep other databases in `sqlite_home` and preserve the existing SQLite log location when `log_dir` is unset. - Initialize and lock the selected log directory only when opening its database, so an unavailable directory does not prevent authentication commands from loading configuration. Share the cleanup lock across configuration clones and allow failed initialization to be retried. - Leave existing logs in place. Read legacy logs for diagnostic submissions when the current query returns no logs, attaching them as `codex-logs-legacy.log`. Continue retention cleanup and page reclamation for both locations, with independent maintenance locks and filesystem checks. ## Testing Add coverage for configuration routing and managed requirements, deferred initialization and lock lifetimes, authentication with an unavailable log directory, legacy log attachments, and cleanup of current and legacy databases. GitOrigin-RevId: 48216694596d738eff1b3c5eef6c4e7ada6d0137
1950 lines
69 KiB
Rust
1950 lines
69 KiB
Rust
use super::*;
|
|
|
|
impl StateRuntime {
|
|
pub async fn insert_log(&self, entry: &LogEntry) -> anyhow::Result<()> {
|
|
self.insert_logs(std::slice::from_ref(entry)).await
|
|
}
|
|
|
|
/// Insert a batch of log entries into the logs table.
|
|
pub async fn insert_logs(&self, entries: &[LogEntry]) -> anyhow::Result<()> {
|
|
if entries.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut tx = self.logs_pool.begin().await?;
|
|
let mut builder = QueryBuilder::<Sqlite>::new(
|
|
"INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, thread_id, process_uuid, module_path, file, line, estimated_bytes) ",
|
|
);
|
|
builder.push_values(entries, |mut row, entry| {
|
|
let feedback_log_body = entry.feedback_log_body.as_ref().or(entry.message.as_ref());
|
|
// Keep about 10 MiB of reader-visible log content per partition.
|
|
// Both `query_logs` and `/feedback` read the persisted
|
|
// `feedback_log_body`, while `LogEntry.message` is only a write-time
|
|
// fallback for callers that still populate the old field.
|
|
row.push_bind(entry.ts)
|
|
.push_bind(entry.ts_nanos)
|
|
.push_bind(&entry.level)
|
|
.push_bind(&entry.target)
|
|
.push_bind(feedback_log_body)
|
|
.push_bind(&entry.thread_id)
|
|
.push_bind(&entry.process_uuid)
|
|
.push_bind(&entry.module_path)
|
|
.push_bind(&entry.file)
|
|
.push_bind(entry.line)
|
|
.push_bind(entry.estimated_bytes());
|
|
});
|
|
builder.build().execute(&mut *tx).await?;
|
|
self.prune_logs_after_insert(entries, &mut tx).await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Enforce per-partition retained-log-content caps after a successful batch insert.
|
|
///
|
|
/// We maintain two independent budgets:
|
|
/// - Thread logs: rows with `thread_id IS NOT NULL`, capped per `thread_id`.
|
|
/// - Threadless process logs: rows with `thread_id IS NULL` ("threadless"),
|
|
/// capped per `process_uuid` (including `process_uuid IS NULL` as its own
|
|
/// threadless partition).
|
|
///
|
|
/// "Threadless" means the log row is not associated with any conversation
|
|
/// thread, so retention is keyed by process identity instead.
|
|
///
|
|
/// This runs inside the same transaction as the insert so callers never
|
|
/// observe "inserted but not yet pruned" rows.
|
|
async fn prune_logs_after_insert(
|
|
&self,
|
|
entries: &[LogEntry],
|
|
tx: &mut SqliteConnection,
|
|
) -> anyhow::Result<()> {
|
|
let thread_ids: BTreeSet<&str> = entries
|
|
.iter()
|
|
.filter_map(|entry| entry.thread_id.as_deref())
|
|
.collect();
|
|
if !thread_ids.is_empty() {
|
|
// Cheap precheck: only run the heavier window-function prune for
|
|
// threads that are currently above the cap.
|
|
let mut over_limit_threads_query =
|
|
QueryBuilder::<Sqlite>::new("SELECT thread_id FROM logs WHERE thread_id IN (");
|
|
{
|
|
let mut separated = over_limit_threads_query.separated(", ");
|
|
for thread_id in &thread_ids {
|
|
separated.push_bind(*thread_id);
|
|
}
|
|
}
|
|
over_limit_threads_query.push(") GROUP BY thread_id HAVING SUM(");
|
|
over_limit_threads_query.push("estimated_bytes");
|
|
over_limit_threads_query.push(") > ");
|
|
over_limit_threads_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
over_limit_threads_query.push(" OR COUNT(*) > ");
|
|
over_limit_threads_query.push_bind(LOG_PARTITION_ROW_LIMIT);
|
|
let over_limit_thread_ids: Vec<String> = over_limit_threads_query
|
|
.build()
|
|
.fetch_all(&mut *tx)
|
|
.await?
|
|
.into_iter()
|
|
.map(|row| row.try_get("thread_id"))
|
|
.collect::<Result<_, _>>()?;
|
|
if !over_limit_thread_ids.is_empty() {
|
|
// Enforce a strict per-thread cap by deleting every row whose
|
|
// newest-first cumulative bytes exceed the partition budget.
|
|
let mut prune_threads = QueryBuilder::<Sqlite>::new(
|
|
r#"
|
|
DELETE FROM logs
|
|
WHERE id IN (
|
|
SELECT id
|
|
FROM (
|
|
SELECT
|
|
id,
|
|
SUM(
|
|
"#,
|
|
);
|
|
prune_threads.push("estimated_bytes");
|
|
prune_threads.push(
|
|
r#"
|
|
) OVER (
|
|
PARTITION BY thread_id
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS cumulative_bytes,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY thread_id
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS row_number
|
|
FROM logs
|
|
WHERE thread_id IN (
|
|
"#,
|
|
);
|
|
{
|
|
let mut separated = prune_threads.separated(", ");
|
|
for thread_id in &over_limit_thread_ids {
|
|
separated.push_bind(thread_id);
|
|
}
|
|
}
|
|
prune_threads.push(
|
|
r#"
|
|
)
|
|
)
|
|
WHERE cumulative_bytes >
|
|
"#,
|
|
);
|
|
prune_threads.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
prune_threads.push(" OR row_number > ");
|
|
prune_threads.push_bind(LOG_PARTITION_ROW_LIMIT);
|
|
prune_threads.push("\n)");
|
|
prune_threads.build().execute(&mut *tx).await?;
|
|
}
|
|
}
|
|
|
|
let threadless_process_uuids: BTreeSet<&str> = entries
|
|
.iter()
|
|
.filter(|entry| entry.thread_id.is_none())
|
|
.filter_map(|entry| entry.process_uuid.as_deref())
|
|
.collect();
|
|
let has_threadless_null_process_uuid = entries
|
|
.iter()
|
|
.any(|entry| entry.thread_id.is_none() && entry.process_uuid.is_none());
|
|
if !threadless_process_uuids.is_empty() {
|
|
// Threadless logs are budgeted separately per process UUID.
|
|
let mut over_limit_processes_query = QueryBuilder::<Sqlite>::new(
|
|
"SELECT process_uuid FROM logs WHERE thread_id IS NULL AND process_uuid IN (",
|
|
);
|
|
{
|
|
let mut separated = over_limit_processes_query.separated(", ");
|
|
for process_uuid in &threadless_process_uuids {
|
|
separated.push_bind(*process_uuid);
|
|
}
|
|
}
|
|
over_limit_processes_query.push(") GROUP BY process_uuid HAVING SUM(");
|
|
over_limit_processes_query.push("estimated_bytes");
|
|
over_limit_processes_query.push(") > ");
|
|
over_limit_processes_query.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
over_limit_processes_query.push(" OR COUNT(*) > ");
|
|
over_limit_processes_query.push_bind(LOG_PARTITION_ROW_LIMIT);
|
|
let over_limit_process_uuids: Vec<String> = over_limit_processes_query
|
|
.build()
|
|
.fetch_all(&mut *tx)
|
|
.await?
|
|
.into_iter()
|
|
.map(|row| row.try_get("process_uuid"))
|
|
.collect::<Result<_, _>>()?;
|
|
if !over_limit_process_uuids.is_empty() {
|
|
// Same strict cap policy as thread pruning, but only for
|
|
// threadless rows in the affected process UUIDs.
|
|
let mut prune_threadless_process_logs = QueryBuilder::<Sqlite>::new(
|
|
r#"
|
|
DELETE FROM logs
|
|
WHERE id IN (
|
|
SELECT id
|
|
FROM (
|
|
SELECT
|
|
id,
|
|
SUM(
|
|
"#,
|
|
);
|
|
prune_threadless_process_logs.push("estimated_bytes");
|
|
prune_threadless_process_logs.push(
|
|
r#"
|
|
) OVER (
|
|
PARTITION BY process_uuid
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS cumulative_bytes,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY process_uuid
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS row_number
|
|
FROM logs
|
|
WHERE thread_id IS NULL
|
|
AND process_uuid IN (
|
|
"#,
|
|
);
|
|
{
|
|
let mut separated = prune_threadless_process_logs.separated(", ");
|
|
for process_uuid in &over_limit_process_uuids {
|
|
separated.push_bind(process_uuid);
|
|
}
|
|
}
|
|
prune_threadless_process_logs.push(
|
|
r#"
|
|
)
|
|
)
|
|
WHERE cumulative_bytes >
|
|
"#,
|
|
);
|
|
prune_threadless_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
prune_threadless_process_logs.push(" OR row_number > ");
|
|
prune_threadless_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT);
|
|
prune_threadless_process_logs.push("\n)");
|
|
prune_threadless_process_logs
|
|
.build()
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
if has_threadless_null_process_uuid {
|
|
// Rows without a process UUID still need a cap; treat NULL as its
|
|
// own threadless partition.
|
|
let mut null_process_usage_query = QueryBuilder::<Sqlite>::new("SELECT SUM(");
|
|
null_process_usage_query.push("estimated_bytes");
|
|
null_process_usage_query.push(
|
|
") AS total_bytes, COUNT(*) AS row_count FROM logs WHERE thread_id IS NULL AND process_uuid IS NULL",
|
|
);
|
|
let null_process_usage = null_process_usage_query.build().fetch_one(&mut *tx).await?;
|
|
let total_null_process_bytes: Option<i64> =
|
|
null_process_usage.try_get("total_bytes")?;
|
|
let null_process_row_count: i64 = null_process_usage.try_get("row_count")?;
|
|
|
|
if total_null_process_bytes.unwrap_or(0) > LOG_PARTITION_SIZE_LIMIT_BYTES
|
|
|| null_process_row_count > LOG_PARTITION_ROW_LIMIT
|
|
{
|
|
let mut prune_threadless_null_process_logs = QueryBuilder::<Sqlite>::new(
|
|
r#"
|
|
DELETE FROM logs
|
|
WHERE id IN (
|
|
SELECT id
|
|
FROM (
|
|
SELECT
|
|
id,
|
|
SUM(
|
|
"#,
|
|
);
|
|
prune_threadless_null_process_logs.push("estimated_bytes");
|
|
prune_threadless_null_process_logs.push(
|
|
r#"
|
|
) OVER (
|
|
PARTITION BY process_uuid
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS cumulative_bytes,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY process_uuid
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS row_number
|
|
FROM logs
|
|
WHERE thread_id IS NULL
|
|
AND process_uuid IS NULL
|
|
)
|
|
WHERE cumulative_bytes >
|
|
"#,
|
|
);
|
|
prune_threadless_null_process_logs.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
prune_threadless_null_process_logs.push(" OR row_number > ");
|
|
prune_threadless_null_process_logs.push_bind(LOG_PARTITION_ROW_LIMIT);
|
|
prune_threadless_null_process_logs.push("\n)");
|
|
prune_threadless_null_process_logs
|
|
.build()
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Query logs with optional filters.
|
|
pub async fn query_logs(&self, query: &LogQuery) -> anyhow::Result<Vec<LogRow>> {
|
|
let mut builder = QueryBuilder::<Sqlite>::new(
|
|
"SELECT id, ts, ts_nanos, level, target, feedback_log_body AS message, thread_id, process_uuid, file, line FROM logs WHERE 1 = 1",
|
|
);
|
|
push_log_filters(&mut builder, query);
|
|
if query.descending {
|
|
builder.push(" ORDER BY id DESC");
|
|
} else {
|
|
builder.push(" ORDER BY id ASC");
|
|
}
|
|
if let Some(limit) = query.limit {
|
|
builder.push(" LIMIT ").push_bind(limit as i64);
|
|
}
|
|
|
|
let rows = builder
|
|
.build_query_as::<LogRow>()
|
|
.fetch_all(self.logs_pool.as_ref())
|
|
.await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// Query feedback logs for a set of threads, capped to the SQLite retention budget.
|
|
pub async fn query_feedback_logs_for_threads(
|
|
&self,
|
|
thread_ids: &[&str],
|
|
) -> anyhow::Result<Vec<u8>> {
|
|
Self::query_feedback_logs_from_pool(self.logs_pool.as_ref(), thread_ids).await
|
|
}
|
|
|
|
/// Read logs from the previous directory without creating or migrating its database.
|
|
pub async fn query_legacy_feedback_logs_for_threads(
|
|
&self,
|
|
thread_ids: &[&str],
|
|
) -> anyhow::Result<Vec<u8>> {
|
|
let Some(path) = self.sqlite.legacy_logs_db_path() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
if thread_ids.is_empty() || !tokio::fs::try_exists(&path).await? {
|
|
return Ok(Vec::new());
|
|
}
|
|
let pool = self
|
|
.sqlite
|
|
.open_read_only_pool(&path, /*busy_timeout*/ None)
|
|
.await?;
|
|
let result = Self::query_feedback_logs_from_pool(&pool, thread_ids).await;
|
|
pool.close().await;
|
|
result
|
|
}
|
|
|
|
async fn query_feedback_logs_from_pool(
|
|
pool: &SqlitePool,
|
|
thread_ids: &[&str],
|
|
) -> anyhow::Result<Vec<u8>> {
|
|
if thread_ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX);
|
|
// Bound the fetched rows in SQL first so over-retained partitions do not have to load
|
|
// every row into memory, then apply the exact whole-line byte cap after formatting.
|
|
let mut builder = QueryBuilder::<Sqlite>::new(
|
|
r#"
|
|
WITH requested_threads(thread_id) AS (
|
|
VALUES
|
|
"#,
|
|
);
|
|
{
|
|
let mut separated = builder.separated(", ");
|
|
for thread_id in thread_ids {
|
|
separated
|
|
.push("(")
|
|
.push_bind_unseparated(*thread_id)
|
|
.push_unseparated(")");
|
|
}
|
|
}
|
|
builder.push(
|
|
r#"
|
|
),
|
|
latest_processes AS (
|
|
SELECT (
|
|
SELECT process_uuid
|
|
FROM logs
|
|
WHERE logs.thread_id = requested_threads.thread_id AND process_uuid IS NOT NULL
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
LIMIT 1
|
|
) AS process_uuid
|
|
FROM requested_threads
|
|
),
|
|
feedback_logs AS (
|
|
SELECT ts, ts_nanos, level, feedback_log_body, estimated_bytes, id
|
|
FROM logs
|
|
WHERE feedback_log_body IS NOT NULL AND (
|
|
thread_id IN (SELECT thread_id FROM requested_threads)
|
|
OR (
|
|
thread_id IS NULL
|
|
AND process_uuid IN (
|
|
SELECT process_uuid
|
|
FROM latest_processes
|
|
WHERE process_uuid IS NOT NULL
|
|
)
|
|
)
|
|
)
|
|
),
|
|
bounded_feedback_logs AS (
|
|
SELECT
|
|
ts,
|
|
ts_nanos,
|
|
level,
|
|
feedback_log_body,
|
|
id,
|
|
SUM(estimated_bytes) OVER (
|
|
ORDER BY ts DESC, ts_nanos DESC, id DESC
|
|
) AS cumulative_estimated_bytes
|
|
FROM feedback_logs
|
|
)
|
|
SELECT ts, ts_nanos, level, feedback_log_body
|
|
FROM bounded_feedback_logs
|
|
WHERE cumulative_estimated_bytes <=
|
|
"#,
|
|
);
|
|
builder.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES);
|
|
builder.push(" ORDER BY ts DESC, ts_nanos DESC, id DESC");
|
|
let rows = builder
|
|
.build_query_as::<FeedbackLogRow>()
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
let mut lines = Vec::new();
|
|
let mut total_bytes = 0usize;
|
|
for row in rows {
|
|
let line =
|
|
format_feedback_log_line(row.ts, row.ts_nanos, &row.level, &row.feedback_log_body);
|
|
if total_bytes.saturating_add(line.len()) > max_bytes {
|
|
break;
|
|
}
|
|
total_bytes += line.len();
|
|
lines.push(line);
|
|
}
|
|
|
|
let mut ordered_bytes = Vec::with_capacity(total_bytes);
|
|
for line in lines.into_iter().rev() {
|
|
ordered_bytes.extend_from_slice(line.as_bytes());
|
|
}
|
|
|
|
Ok(ordered_bytes)
|
|
}
|
|
|
|
/// Query per-thread feedback logs, capped to the per-thread SQLite retention budget.
|
|
pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result<Vec<u8>> {
|
|
self.query_feedback_logs_for_threads(&[thread_id]).await
|
|
}
|
|
|
|
/// Return the max log id matching optional filters.
|
|
pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result<i64> {
|
|
let mut builder =
|
|
QueryBuilder::<Sqlite>::new("SELECT MAX(id) AS max_id FROM logs WHERE 1 = 1");
|
|
push_log_filters(&mut builder, query);
|
|
let row = builder.build().fetch_one(self.logs_pool.as_ref()).await?;
|
|
let max_id: Option<i64> = row.try_get("max_id")?;
|
|
Ok(max_id.unwrap_or(0))
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx_macros::FromRow)]
|
|
struct FeedbackLogRow {
|
|
ts: i64,
|
|
ts_nanos: i64,
|
|
level: String,
|
|
feedback_log_body: String,
|
|
}
|
|
|
|
fn format_feedback_log_line(
|
|
ts: i64,
|
|
ts_nanos: i64,
|
|
level: &str,
|
|
feedback_log_body: &str,
|
|
) -> String {
|
|
let nanos = u32::try_from(ts_nanos).unwrap_or(0);
|
|
let timestamp = match DateTime::<Utc>::from_timestamp(ts, nanos) {
|
|
Some(dt) => dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true),
|
|
None => format!("{ts}.{ts_nanos:09}Z"),
|
|
};
|
|
let mut line = format!("{timestamp} {level:>5} {feedback_log_body}");
|
|
if !line.ends_with('\n') {
|
|
line.push('\n');
|
|
}
|
|
line
|
|
}
|
|
|
|
fn push_log_filters(builder: &mut QueryBuilder<Sqlite>, query: &LogQuery) {
|
|
if !query.levels_upper.is_empty() {
|
|
builder.push(" AND UPPER(level) IN (");
|
|
{
|
|
let mut separated = builder.separated(", ");
|
|
for level_upper in &query.levels_upper {
|
|
separated.push_bind(level_upper.as_str());
|
|
}
|
|
}
|
|
builder.push(")");
|
|
}
|
|
if let Some(from_ts) = query.from_ts {
|
|
builder.push(" AND ts >= ").push_bind(from_ts);
|
|
}
|
|
if let Some(to_ts) = query.to_ts {
|
|
builder.push(" AND ts <= ").push_bind(to_ts);
|
|
}
|
|
push_like_filters(builder, "module_path", &query.module_like);
|
|
push_like_filters(builder, "file", &query.file_like);
|
|
let has_thread_filter = !query.thread_ids.is_empty() || query.include_threadless;
|
|
if has_thread_filter {
|
|
builder.push(" AND (");
|
|
let mut needs_or = false;
|
|
for thread_id in &query.thread_ids {
|
|
if needs_or {
|
|
builder.push(" OR ");
|
|
}
|
|
builder.push("thread_id = ").push_bind(thread_id.as_str());
|
|
needs_or = true;
|
|
}
|
|
if query.include_threadless {
|
|
if needs_or {
|
|
builder.push(" OR ");
|
|
}
|
|
builder.push("thread_id IS NULL");
|
|
}
|
|
builder.push(")");
|
|
}
|
|
if let Some(after_id) = query.after_id {
|
|
builder.push(" AND id > ").push_bind(after_id);
|
|
}
|
|
if let Some(search) = query.search.as_ref() {
|
|
builder.push(" AND INSTR(COALESCE(feedback_log_body, ''), ");
|
|
builder.push_bind(search.as_str());
|
|
builder.push(") > 0");
|
|
}
|
|
}
|
|
|
|
fn push_like_filters(builder: &mut QueryBuilder<Sqlite>, column: &str, filters: &[String]) {
|
|
if filters.is_empty() {
|
|
return;
|
|
}
|
|
builder.push(" AND (");
|
|
for (idx, filter) in filters.iter().enumerate() {
|
|
if idx > 0 {
|
|
builder.push(" OR ");
|
|
}
|
|
builder
|
|
.push(column)
|
|
.push(" LIKE '%' || ")
|
|
.push_bind(filter.as_str())
|
|
.push(" || '%'");
|
|
}
|
|
builder.push(")");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::StateRuntime;
|
|
use super::format_feedback_log_line;
|
|
use super::test_support::unique_temp_dir;
|
|
use crate::LogEntry;
|
|
use crate::LogQuery;
|
|
use crate::migrations::LOGS_MIGRATOR;
|
|
use chrono::Utc;
|
|
use codex_utils_absolute_path::test_support::PathExt;
|
|
use pretty_assertions::assert_eq;
|
|
use sqlx::SqlitePool;
|
|
use sqlx::migrate::Migrator;
|
|
use std::borrow::Cow;
|
|
use std::path::Path;
|
|
|
|
async fn open_db_pool(path: &Path) -> SqlitePool {
|
|
crate::SqliteConfig::new_for_testing(path.parent().unwrap_or(path).abs())
|
|
.open_read_write_pool(path)
|
|
.await
|
|
.expect("open sqlite pool")
|
|
}
|
|
|
|
async fn log_row_count(path: &Path) -> i64 {
|
|
let pool = open_db_pool(path).await;
|
|
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM logs")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("count log rows");
|
|
pool.close().await;
|
|
count
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_use_dedicated_log_database() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("dedicated-log-db".to_string()),
|
|
feedback_log_body: Some("dedicated-log-db".to_string()),
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
module_path: Some("mod".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(7),
|
|
}])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let logs_path =
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()).logs_db_path();
|
|
let logs_count = log_row_count(logs_path.as_path()).await;
|
|
|
|
assert_eq!(logs_count, 1);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_migrates_message_only_logs_db_to_feedback_log_body_schema() {
|
|
let codex_home = unique_temp_dir();
|
|
tokio::fs::create_dir_all(&codex_home)
|
|
.await
|
|
.expect("create codex home");
|
|
let logs_path =
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()).logs_db_path();
|
|
let old_logs_migrator = Migrator {
|
|
migrations: Cow::Owned(vec![LOGS_MIGRATOR.migrations[0].clone()]),
|
|
ignore_missing: false,
|
|
locking: true,
|
|
no_tx: false,
|
|
table_name: LOGS_MIGRATOR.table_name.clone(),
|
|
create_schemas: LOGS_MIGRATOR.create_schemas.clone(),
|
|
};
|
|
let pool = crate::SqliteConfig::new_for_testing(codex_home.as_path().abs())
|
|
.open_read_write_pool(&logs_path)
|
|
.await
|
|
.expect("open old logs db");
|
|
old_logs_migrator
|
|
.run(&pool)
|
|
.await
|
|
.expect("apply old logs schema");
|
|
sqlx::query(
|
|
"INSERT INTO logs (ts, ts_nanos, level, target, message, module_path, file, line, thread_id, process_uuid, estimated_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(Utc::now().timestamp())
|
|
.bind(0_i64)
|
|
.bind("INFO")
|
|
.bind("cli")
|
|
.bind("legacy-body")
|
|
.bind("mod")
|
|
.bind("main.rs")
|
|
.bind(7_i64)
|
|
.bind("thread-1")
|
|
.bind("proc-1")
|
|
.bind(16_i64)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert legacy log row");
|
|
pool.close().await;
|
|
drop(pool);
|
|
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery::default())
|
|
.await
|
|
.expect("query migrated logs");
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].message.as_deref(), Some("legacy-body"));
|
|
|
|
let migrated_pool = open_db_pool(logs_path.as_path()).await;
|
|
let columns = sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info('logs')")
|
|
.fetch_all(&migrated_pool)
|
|
.await
|
|
.expect("load migrated columns");
|
|
assert_eq!(
|
|
columns,
|
|
vec![
|
|
"id".to_string(),
|
|
"ts".to_string(),
|
|
"ts_nanos".to_string(),
|
|
"level".to_string(),
|
|
"target".to_string(),
|
|
"feedback_log_body".to_string(),
|
|
"module_path".to_string(),
|
|
"file".to_string(),
|
|
"line".to_string(),
|
|
"thread_id".to_string(),
|
|
"process_uuid".to_string(),
|
|
"estimated_bytes".to_string(),
|
|
]
|
|
);
|
|
let indexes = sqlx::query_scalar::<_, String>(
|
|
"SELECT name FROM pragma_index_list('logs') ORDER BY name",
|
|
)
|
|
.fetch_all(&migrated_pool)
|
|
.await
|
|
.expect("load migrated indexes");
|
|
assert_eq!(
|
|
indexes,
|
|
vec![
|
|
"idx_logs_process_uuid_threadless_ts".to_string(),
|
|
"idx_logs_thread_id".to_string(),
|
|
"idx_logs_thread_id_ts".to_string(),
|
|
"idx_logs_ts".to_string(),
|
|
]
|
|
);
|
|
migrated_pool.close().await;
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_configures_logs_db_with_incremental_auto_vacuum() {
|
|
let codex_home = unique_temp_dir();
|
|
let _runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let logs_path =
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()).logs_db_path();
|
|
let pool = open_db_pool(logs_path.as_path()).await;
|
|
let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("read auto_vacuum pragma");
|
|
assert_eq!(auto_vacuum, 2);
|
|
pool.close().await;
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[test]
|
|
fn format_feedback_log_line_matches_feedback_formatter_shape() {
|
|
assert_eq!(
|
|
format_feedback_log_line(
|
|
/*ts*/ 1,
|
|
/*ts_nanos*/ 123_456_000,
|
|
"INFO",
|
|
"alpha"
|
|
),
|
|
"1970-01-01T00:00:01.123456Z INFO alpha\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_feedback_log_line_preserves_existing_trailing_newline() {
|
|
assert_eq!(
|
|
format_feedback_log_line(
|
|
/*ts*/ 1,
|
|
/*ts_nanos*/ 123_456_000,
|
|
"INFO",
|
|
"alpha\n"
|
|
),
|
|
"1970-01-01T00:00:01.123456Z INFO alpha\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_logs_with_search_matches_rendered_body_substring() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("alpha".to_string()),
|
|
feedback_log_body: Some("foo=1 alpha".to_string()),
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(42),
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("alphabet".to_string()),
|
|
feedback_log_body: Some("foo=2 alphabet".to_string()),
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(43),
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
search: Some("foo=2".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query matching logs");
|
|
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].message.as_deref(), Some("foo=2 alphabet"));
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_logs_filters_level_set_without_rewriting_stored_level() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "TRACE".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("trace-row".to_string()),
|
|
feedback_log_body: Some("trace-row".to_string()),
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("info-row".to_string()),
|
|
feedback_log_body: Some("info-row".to_string()),
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(2),
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "warn".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("warn-row".to_string()),
|
|
feedback_log_body: Some("warn-row".to_string()),
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(3),
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 4,
|
|
ts_nanos: 0,
|
|
level: "ERROR".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("error-row".to_string()),
|
|
feedback_log_body: Some("error-row".to_string()),
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(4),
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
levels_upper: vec!["WARN".to_string(), "ERROR".to_string()],
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query matching logs");
|
|
let actual = rows
|
|
.iter()
|
|
.map(|row| (row.level.as_str(), row.message.as_deref()))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
actual,
|
|
vec![("warn", Some("warn-row")), ("ERROR", Some("error-row"))]
|
|
);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_old_rows_when_thread_exceeds_size_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let six_mebibytes = "a".repeat(6 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: Some(six_mebibytes.clone()),
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: Some(six_mebibytes.clone()),
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(2),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
thread_ids: vec!["thread-1".to_string()],
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query thread logs");
|
|
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].ts, now + 2);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_single_thread_row_when_it_exceeds_size_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let eleven_mebibytes = "d".repeat(11 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: Some(eleven_mebibytes),
|
|
thread_id: Some("thread-oversized".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
}])
|
|
.await
|
|
.expect("insert test log");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
thread_ids: vec!["thread-oversized".to_string()],
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query thread logs");
|
|
|
|
assert!(rows.is_empty());
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_threadless_rows_per_process_uuid_only() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let six_mebibytes = "b".repeat(6 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(six_mebibytes.clone()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(six_mebibytes.clone()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(2),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(six_mebibytes),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(3),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
thread_ids: vec!["thread-1".to_string()],
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query thread and threadless logs");
|
|
|
|
let mut timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
|
|
timestamps.sort_unstable();
|
|
assert_eq!(timestamps, vec![now + 2, now + 3]);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_single_threadless_process_row_when_it_exceeds_size_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let eleven_mebibytes = "e".repeat(11 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: Some(eleven_mebibytes),
|
|
thread_id: None,
|
|
process_uuid: Some("proc-oversized".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
}])
|
|
.await
|
|
.expect("insert test log");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query threadless logs");
|
|
|
|
assert!(rows.is_empty());
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_threadless_rows_with_null_process_uuid() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let six_mebibytes = "c".repeat(6 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(six_mebibytes.clone()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(six_mebibytes),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(2),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(3),
|
|
module_path: Some("mod".to_string()),
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query threadless logs");
|
|
|
|
let mut timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
|
|
timestamps.sort_unstable();
|
|
assert_eq!(timestamps, vec![now + 2, now + 3]);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_single_threadless_null_process_row_when_it_exceeds_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let eleven_mebibytes = "f".repeat(11 * 1024 * 1024);
|
|
runtime
|
|
.insert_logs(&[LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: Some(eleven_mebibytes),
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(1),
|
|
module_path: Some("mod".to_string()),
|
|
}])
|
|
.await
|
|
.expect("insert test log");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query threadless logs");
|
|
|
|
assert!(rows.is_empty());
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_old_rows_when_thread_exceeds_row_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let entries: Vec<LogEntry> = (1..=1_001)
|
|
.map(|ts| LogEntry {
|
|
ts: now + ts,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(format!("thread-row-{ts}")),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-row-limit".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(ts),
|
|
module_path: Some("mod".to_string()),
|
|
})
|
|
.collect();
|
|
runtime
|
|
.insert_logs(&entries)
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
thread_ids: vec!["thread-row-limit".to_string()],
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query thread logs");
|
|
|
|
let timestamps: Vec<i64> = rows.into_iter().map(|row| row.ts).collect();
|
|
assert_eq!(timestamps.len(), 1_000);
|
|
assert_eq!(timestamps.first().copied(), Some(now + 2));
|
|
assert_eq!(timestamps.last().copied(), Some(now + 1_001));
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_old_threadless_rows_when_process_exceeds_row_limit() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let entries: Vec<LogEntry> = (1..=1_001)
|
|
.map(|ts| LogEntry {
|
|
ts: now + ts,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(format!("process-row-{ts}")),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-row-limit".to_string()),
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(ts),
|
|
module_path: Some("mod".to_string()),
|
|
})
|
|
.collect();
|
|
runtime
|
|
.insert_logs(&entries)
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query threadless logs");
|
|
|
|
let timestamps: Vec<i64> = rows
|
|
.into_iter()
|
|
.filter(|row| row.process_uuid.as_deref() == Some("proc-row-limit"))
|
|
.map(|row| row.ts)
|
|
.collect();
|
|
assert_eq!(timestamps.len(), 1_000);
|
|
assert_eq!(timestamps.first().copied(), Some(now + 2));
|
|
assert_eq!(timestamps.last().copied(), Some(now + 1_001));
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_logs_prunes_old_threadless_null_process_rows_when_row_limit_exceeded() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let entries: Vec<LogEntry> = (1..=1_001)
|
|
.map(|ts| LogEntry {
|
|
ts: now + ts,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(format!("null-process-row-{ts}")),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: None,
|
|
file: Some("main.rs".to_string()),
|
|
line: Some(ts),
|
|
module_path: Some("mod".to_string()),
|
|
})
|
|
.collect();
|
|
runtime
|
|
.insert_logs(&entries)
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let rows = runtime
|
|
.query_logs(&LogQuery {
|
|
include_threadless: true,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("query threadless logs");
|
|
|
|
let timestamps: Vec<i64> = rows
|
|
.into_iter()
|
|
.filter(|row| row.process_uuid.is_none())
|
|
.map(|row| row.ts)
|
|
.collect();
|
|
assert_eq!(timestamps.len(), 1_000);
|
|
assert_eq!(timestamps.first().copied(), Some(now + 2));
|
|
assert_eq!(timestamps.last().copied(), Some(now + 1_001));
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_returns_newest_lines_within_limit_in_order() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("alpha".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("bravo".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("charlie".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs("thread-1")
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("valid utf-8"),
|
|
[
|
|
format_feedback_log_line(/*ts*/ now + 1, /*ts_nanos*/ 0, "INFO", "alpha"),
|
|
format_feedback_log_line(/*ts*/ now + 2, /*ts_nanos*/ 0, "INFO", "bravo"),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 3,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"charlie"
|
|
),
|
|
]
|
|
.concat()
|
|
);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_excludes_oversized_newest_row() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
let eleven_mebibytes = "z".repeat(11 * 1024 * 1024);
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("small".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-oversized".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(eleven_mebibytes),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-oversized".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs("thread-oversized")
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(bytes, Vec::<u8>::new());
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_includes_threadless_rows_from_same_process() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("threadless-before".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("thread-scoped".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("threadless-after".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 4,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("other-process-threadless".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-2".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs("thread-1")
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("valid utf-8"),
|
|
[
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 1,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"threadless-before"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 2,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"thread-scoped"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 3,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"threadless-after"
|
|
),
|
|
]
|
|
.concat()
|
|
);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_excludes_threadless_rows_from_prior_processes() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("old-process-threadless".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-old".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("old-process-thread".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-old".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("new-process-thread".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-new".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 4,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("new-process-threadless".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-new".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs("thread-1")
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("valid utf-8"),
|
|
[
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 2,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"old-process-thread"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 3,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"new-process-thread"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 4,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"new-process-threadless"
|
|
),
|
|
]
|
|
.concat()
|
|
);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_keeps_newest_suffix_across_thread_and_threadless_logs() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
let thread_marker = "thread-scoped-oldest";
|
|
let threadless_older_marker = "threadless-older";
|
|
let threadless_newer_marker = "threadless-newer";
|
|
let five_mebibytes = format!("{threadless_older_marker} {}", "a".repeat(5 * 1024 * 1024));
|
|
let four_and_half_mebibytes = format!(
|
|
"{threadless_newer_marker} {}",
|
|
"b".repeat((9 * 1024 * 1024) / 2)
|
|
);
|
|
let one_mebibyte = format!("{thread_marker} {}", "c".repeat(1024 * 1024));
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(one_mebibyte.clone()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(five_mebibytes),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some(four_and_half_mebibytes),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs("thread-1")
|
|
.await
|
|
.expect("query feedback logs");
|
|
let logs = String::from_utf8(bytes).expect("valid utf-8");
|
|
|
|
assert!(!logs.contains(thread_marker));
|
|
assert!(logs.contains(threadless_older_marker));
|
|
assert!(logs.contains(threadless_newer_marker));
|
|
assert_eq!(logs.matches('\n').count(), 2);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_for_threads_merges_requested_threads_and_threadless_rows() {
|
|
let now = chrono::Utc::now().timestamp();
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
runtime
|
|
.insert_logs(&[
|
|
LogEntry {
|
|
ts: now + 1,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("thread-1".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-1".to_string()),
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 2,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("thread-2".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-2".to_string()),
|
|
process_uuid: Some("proc-2".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 3,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("threadless-proc-1".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-1".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 4,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("threadless-proc-2".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-2".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 5,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("thread-3".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: Some("thread-3".to_string()),
|
|
process_uuid: Some("proc-3".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
LogEntry {
|
|
ts: now + 6,
|
|
ts_nanos: 0,
|
|
level: "INFO".to_string(),
|
|
target: "cli".to_string(),
|
|
message: Some("threadless-proc-3".to_string()),
|
|
feedback_log_body: None,
|
|
thread_id: None,
|
|
process_uuid: Some("proc-3".to_string()),
|
|
file: None,
|
|
line: None,
|
|
module_path: None,
|
|
},
|
|
])
|
|
.await
|
|
.expect("insert test logs");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs_for_threads(&["thread-1", "thread-2"])
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("valid utf-8"),
|
|
[
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 1,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"thread-1"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 2,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"thread-2"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 3,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"threadless-proc-1"
|
|
),
|
|
format_feedback_log_line(
|
|
/*ts*/ now + 4,
|
|
/*ts_nanos*/ 0,
|
|
"INFO",
|
|
"threadless-proc-2"
|
|
),
|
|
]
|
|
.concat()
|
|
);
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_feedback_logs_for_threads_returns_empty_for_empty_thread_list() {
|
|
let codex_home = unique_temp_dir();
|
|
let runtime = StateRuntime::init(
|
|
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
|
|
"test-provider".to_string(),
|
|
)
|
|
.await
|
|
.expect("initialize runtime");
|
|
|
|
let bytes = runtime
|
|
.query_feedback_logs_for_threads(&[])
|
|
.await
|
|
.expect("query feedback logs");
|
|
|
|
assert_eq!(bytes, Vec::<u8>::new());
|
|
|
|
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
|
}
|
|
}
|