Files
codex/prs/bolinfest/PR-1596.md
2025-09-02 15:17:45 -07:00

10 KiB

PR #1596: Storing the sessions in a more organized way for easier look up.

Description

now storing the sessions in ~/.codex/sessions/YYYY/MM/DD/<file>

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index e59dbfa255..b1aa13a12c 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -683,6 +683,7 @@ dependencies = [
  "tree-sitter",
  "tree-sitter-bash",
  "uuid",
+ "walkdir",
  "wildmatch",
  "wiremock",
 ]
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index c55d7d395d..ff066cc534 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -65,4 +65,5 @@ predicates = "3"
 pretty_assertions = "1.4.1"
 tempfile = "3"
 tokio-test = "0.4"
+walkdir = "2.5.0"
 wiremock = "0.6"
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
index c18a58df06..0ff2e94a3a 100644
--- a/codex-rs/core/src/rollout.rs
+++ b/codex-rs/core/src/rollout.rs
@@ -153,14 +153,16 @@ struct LogFileInfo {
 }
 
 fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
-    // Resolve ~/.codex/sessions and create it if missing.
+    // Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
+    let timestamp = OffsetDateTime::now_local()
+        .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
     let mut dir = config.codex_home.clone();
     dir.push(SESSIONS_SUBDIR);
+    dir.push(timestamp.year().to_string());
+    dir.push(format!("{:02}", u8::from(timestamp.month())));
+    dir.push(format!("{:02}", timestamp.day()));
     fs::create_dir_all(&dir)?;
 
-    let timestamp = OffsetDateTime::now_local()
-        .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
-
     // Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for
     // compatibility with filesystems that do not allow colons in filenames.
     let format: &[FormatItem] =
diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs
index 9ef042eb1a..3669b93f51 100644
--- a/codex-rs/core/tests/cli_stream.rs
+++ b/codex-rs/core/tests/cli_stream.rs
@@ -2,7 +2,12 @@
 
 use assert_cmd::Command as AssertCommand;
 use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
+use serde_json::Value;
+use std::time::Duration;
+use std::time::Instant;
 use tempfile::TempDir;
+use uuid::Uuid;
+use walkdir::WalkDir;
 use wiremock::Mock;
 use wiremock::MockServer;
 use wiremock::ResponseTemplate;
@@ -117,3 +122,154 @@ async fn responses_api_stream_cli() {
     let stdout = String::from_utf8_lossy(&output.stdout);
     assert!(stdout.contains("fixture hello"));
 }
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn integration_creates_and_checks_session_file() {
+    // Honor sandbox network restrictions for CI parity with the other tests.
+    if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+        println!(
+            "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+        );
+        return;
+    }
+
+    // 1. Temp home so we read/write isolated session files.
+    let home = TempDir::new().unwrap();
+
+    // 2. Unique marker we'll look for in the session log.
+    let marker = format!("integration-test-{}", Uuid::new_v4());
+    let prompt = format!("echo {marker}");
+
+    // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic.
+    let fixture =
+        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
+
+    // 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
+    //    which is what records a session.
+    let mut cmd = AssertCommand::new("cargo");
+    cmd.arg("run")
+        .arg("-p")
+        .arg("codex-cli")
+        .arg("--quiet")
+        .arg("--")
+        .arg("exec")
+        .arg("--skip-git-repo-check")
+        .arg("-C")
+        .arg(env!("CARGO_MANIFEST_DIR"))
+        .arg(&prompt);
+    cmd.env("CODEX_HOME", home.path())
+        .env("OPENAI_API_KEY", "dummy")
+        .env("CODEX_RS_SSE_FIXTURE", &fixture)
+        // Required for CLI arg parsing even though fixture short-circuits network usage.
+        .env("OPENAI_BASE_URL", "http://unused.local");
+
+    let output = cmd.output().unwrap();
+    assert!(
+        output.status.success(),
+        "codex-cli exec failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    // 5. Sessions are written asynchronously; wait briefly for the directory to appear.
+    let sessions_dir = home.path().join("sessions");
+    let start = Instant::now();
+    while !sessions_dir.exists() && start.elapsed() < Duration::from_secs(2) {
+        std::thread::sleep(Duration::from_millis(50));
+    }
+
+    // 6. Scan all session files and find the one that contains our marker.
+    let mut matching_files = vec![];
+    for entry in WalkDir::new(&sessions_dir) {
+        let entry = entry.unwrap();
+        if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".jsonl") {
+            let path = entry.path();
+            let content = std::fs::read_to_string(path).unwrap();
+            let mut lines = content.lines();
+            // Skip SessionMeta (first line)
+            let _ = lines.next();
+            for line in lines {
+                let item: Value = serde_json::from_str(line).unwrap();
+                if let Some("message") = item.get("type").and_then(|t| t.as_str()) {
+                    if let Some(content) = item.get("content") {
+                        if content.to_string().contains(&marker) {
+                            matching_files.push(path.to_owned());
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+    }
+    assert_eq!(
+        matching_files.len(),
+        1,
+        "Expected exactly one session file containing the marker, found {}",
+        matching_files.len()
+    );
+    let path = &matching_files[0];
+
+    // 7. Verify directory structure: sessions/YYYY/MM/DD/filename.jsonl
+    let rel = match path.strip_prefix(&sessions_dir) {
+        Ok(r) => r,
+        Err(_) => panic!("session file should live under sessions/"),
+    };
+    let comps: Vec<String> = rel
+        .components()
+        .map(|c| c.as_os_str().to_string_lossy().into_owned())
+        .collect();
+    assert_eq!(
+        comps.len(),
+        4,
+        "Expected sessions/YYYY/MM/DD/<file>, got {rel:?}"
+    );
+    let year = &comps[0];
+    let month = &comps[1];
+    let day = &comps[2];
+    assert!(
+        year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()),
+        "Year dir not 4-digit numeric: {year}"
+    );
+    assert!(
+        month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()),
+        "Month dir not zero-padded 2-digit numeric: {month}"
+    );
+    assert!(
+        day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()),
+        "Day dir not zero-padded 2-digit numeric: {day}"
+    );
+    // Range checks (best-effort; won't fail on leading zeros)
+    if let Ok(m) = month.parse::<u8>() {
+        assert!((1..=12).contains(&m), "Month out of range: {m}");
+    }
+    if let Ok(d) = day.parse::<u8>() {
+        assert!((1..=31).contains(&d), "Day out of range: {d}");
+    }
+
+    // 8. Parse SessionMeta line and basic sanity checks.
+    let content = std::fs::read_to_string(path).unwrap();
+    let mut lines = content.lines();
+    let meta: Value = serde_json::from_str(lines.next().unwrap()).unwrap();
+    assert!(meta.get("id").is_some(), "SessionMeta missing id");
+    assert!(
+        meta.get("timestamp").is_some(),
+        "SessionMeta missing timestamp"
+    );
+
+    // 9. Confirm at least one message contains the marker.
+    let mut found_message = false;
+    for line in lines {
+        let item: Value = serde_json::from_str(line).unwrap();
+        if item.get("type").map(|t| t == "message").unwrap_or(false) {
+            if let Some(content) = item.get("content") {
+                if content.to_string().contains(&marker) {
+                    found_message = true;
+                    break;
+                }
+            }
+        }
+    }
+    assert!(
+        found_message,
+        "No message found in session file containing the marker"
+    );
+}

Review Comments

codex-rs/core/Cargo.toml

@@ -44,6 +44,7 @@ tracing = { version = "0.1.41", features = ["log"] }
 tree-sitter = "0.25.3"
 tree-sitter-bash = "0.25.0"
 uuid = { version = "1", features = ["serde", "v4"] }
+walkdir = "2.5.0"

If this is only used for tests (I hope so?), then please put it under dev-dependencies.

codex-rs/core/tests/cli_stream.rs

@@ -117,3 +122,154 @@ async fn responses_api_stream_cli() {
     let stdout = String::from_utf8_lossy(&output.stdout);
     assert!(stdout.contains("fixture hello"));
 }
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn integration_creates_and_checks_session_file() {
+    // Honor sandbox network restrictions for CI parity with the other tests.
+    if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+        println!(
+            "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+        );
+        return;
+    }
+
+    // 1. Temp home so we read/write isolated session files.
+    let home = TempDir::new().unwrap();
+
+    // 2. Unique marker we'll look for in the session log.
+    let marker = format!("integration-test-{}", Uuid::new_v4());
+    let prompt = format!("echo {marker}");
+
+    // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic.
+    let fixture =
+        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
+
+    // 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`,
+    //    which is what records a session.
+    let mut cmd = AssertCommand::new("cargo");

Why this instead of Command::cargo_bin?