mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
dd1321d11b
Mechanical change to promote absolute paths through code.
419 lines
13 KiB
Rust
419 lines
13 KiB
Rust
//! Persistence layer for the global, append-only *message history* file.
|
|
//!
|
|
//! The history is stored at `~/.codex/history.jsonl` with **one JSON object per
|
|
//! line** so that it can be efficiently appended to and parsed with standard
|
|
//! JSON-Lines tooling. Each record has the following schema:
|
|
//!
|
|
//! ````text
|
|
//! {"conversation_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
|
|
//! ````
|
|
//!
|
|
//! To minimise the chance of interleaved writes when multiple processes are
|
|
//! appending concurrently, callers should *prepare the full line* (record +
|
|
//! trailing `\n`) and write it with a **single `write(2)` system call** while
|
|
//! the file descriptor is opened with the `O_APPEND` flag. POSIX guarantees
|
|
//! that writes up to `PIPE_BUF` bytes are atomic in that case.
|
|
//! Note: `conversation_id` stores the thread id; the field name is preserved for
|
|
//! backwards compatibility with existing history files.
|
|
|
|
use std::fs::File;
|
|
use std::fs::OpenOptions;
|
|
use std::io::BufRead;
|
|
use std::io::BufReader;
|
|
use std::io::Read;
|
|
use std::io::Result;
|
|
use std::io::Seek;
|
|
use std::io::SeekFrom;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
|
|
use std::time::Duration;
|
|
use tokio::fs;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
use crate::config::Config;
|
|
use codex_config::types::HistoryPersistence;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
|
|
use codex_protocol::ThreadId;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
/// Filename that stores the message history inside `~/.codex`.
|
|
const HISTORY_FILENAME: &str = "history.jsonl";
|
|
|
|
/// When history exceeds the hard cap, trim it down to this fraction of `max_bytes`.
|
|
const HISTORY_SOFT_CAP_RATIO: f64 = 0.8;
|
|
|
|
const MAX_RETRIES: usize = 10;
|
|
const RETRY_SLEEP: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct HistoryEntry {
|
|
pub session_id: String,
|
|
pub ts: u64,
|
|
pub text: String,
|
|
}
|
|
|
|
fn history_filepath(config: &Config) -> AbsolutePathBuf {
|
|
config.codex_home.join(HISTORY_FILENAME)
|
|
}
|
|
|
|
/// Append a `text` entry associated with `conversation_id` to the history file.
|
|
///
|
|
/// Uses advisory file locking (`File::try_lock`) with a retry loop to ensure
|
|
/// concurrent writes from multiple TUI processes do not interleave. The lock
|
|
/// acquisition and write are performed inside `spawn_blocking` so the caller's
|
|
/// async runtime is not blocked.
|
|
///
|
|
/// The entry is silently skipped when `config.history.persistence` is
|
|
/// [`HistoryPersistence::None`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an I/O error if the history file cannot be opened/created, the
|
|
/// system clock is before the Unix epoch, or the exclusive lock cannot be
|
|
/// acquired after [`MAX_RETRIES`] attempts.
|
|
pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> {
|
|
match config.history.persistence {
|
|
HistoryPersistence::SaveAll => {
|
|
// Save everything: proceed.
|
|
}
|
|
HistoryPersistence::None => {
|
|
// No history persistence requested.
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// TODO: check `text` for sensitive patterns
|
|
|
|
// Resolve `~/.codex/history.jsonl` and ensure the parent directory exists.
|
|
let path = history_filepath(config);
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
|
|
// Compute timestamp (seconds since the Unix epoch).
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|e| std::io::Error::other(format!("system clock before Unix epoch: {e}")))?
|
|
.as_secs();
|
|
|
|
// Construct the JSON line first so we can write it in a single syscall.
|
|
let entry = HistoryEntry {
|
|
session_id: conversation_id.to_string(),
|
|
ts,
|
|
text: text.to_string(),
|
|
};
|
|
let mut line = serde_json::to_string(&entry)
|
|
.map_err(|e| std::io::Error::other(format!("failed to serialise history entry: {e}")))?;
|
|
line.push('\n');
|
|
|
|
// Open the history file for read/write access (append-only on Unix).
|
|
let mut options = OpenOptions::new();
|
|
options.read(true).write(true).create(true);
|
|
#[cfg(unix)]
|
|
{
|
|
options.append(true);
|
|
options.mode(0o600);
|
|
}
|
|
|
|
let mut history_file = options.open(&path)?;
|
|
|
|
// Ensure permissions.
|
|
ensure_owner_only_permissions(&history_file).await?;
|
|
|
|
let history_max_bytes = config.history.max_bytes;
|
|
|
|
// Perform a blocking write under an advisory write lock using std::fs.
|
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
|
// Retry a few times to avoid indefinite blocking when contended.
|
|
for _ in 0..MAX_RETRIES {
|
|
match history_file.try_lock() {
|
|
Ok(()) => {
|
|
// While holding the exclusive lock, write the full line.
|
|
// We do not open the file with `append(true)` on Windows, so ensure the
|
|
// cursor is positioned at the end before writing.
|
|
history_file.seek(SeekFrom::End(0))?;
|
|
history_file.write_all(line.as_bytes())?;
|
|
history_file.flush()?;
|
|
enforce_history_limit(&mut history_file, history_max_bytes)?;
|
|
return Ok(());
|
|
}
|
|
Err(std::fs::TryLockError::WouldBlock) => {
|
|
std::thread::sleep(RETRY_SLEEP);
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::WouldBlock,
|
|
"could not acquire exclusive lock on history file after multiple attempts",
|
|
))
|
|
})
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Trim the history file to honor `max_bytes`, dropping the oldest lines while holding
|
|
/// the write lock so the newest entry is always retained. When the file exceeds the
|
|
/// hard cap, it rewrites the remaining tail to a soft cap to avoid trimming again
|
|
/// immediately on the next write.
|
|
fn enforce_history_limit(file: &mut File, max_bytes: Option<usize>) -> Result<()> {
|
|
let Some(max_bytes) = max_bytes else {
|
|
return Ok(());
|
|
};
|
|
|
|
if max_bytes == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let max_bytes = match u64::try_from(max_bytes) {
|
|
Ok(value) => value,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
let mut current_len = file.metadata()?.len();
|
|
|
|
if current_len <= max_bytes {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut reader_file = file.try_clone()?;
|
|
reader_file.seek(SeekFrom::Start(0))?;
|
|
|
|
let mut buf_reader = BufReader::new(reader_file);
|
|
let mut line_lengths = Vec::new();
|
|
let mut line_buf = String::new();
|
|
|
|
loop {
|
|
line_buf.clear();
|
|
|
|
let bytes = buf_reader.read_line(&mut line_buf)?;
|
|
|
|
if bytes == 0 {
|
|
break;
|
|
}
|
|
|
|
line_lengths.push(bytes as u64);
|
|
}
|
|
|
|
if line_lengths.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let last_index = line_lengths.len() - 1;
|
|
let trim_target = trim_target_bytes(max_bytes, line_lengths[last_index]);
|
|
|
|
let mut drop_bytes = 0u64;
|
|
let mut idx = 0usize;
|
|
|
|
while current_len > trim_target && idx < last_index {
|
|
current_len = current_len.saturating_sub(line_lengths[idx]);
|
|
drop_bytes += line_lengths[idx];
|
|
idx += 1;
|
|
}
|
|
|
|
if drop_bytes == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut reader = buf_reader.into_inner();
|
|
reader.seek(SeekFrom::Start(drop_bytes))?;
|
|
|
|
let capacity = usize::try_from(current_len).unwrap_or(0);
|
|
let mut tail = Vec::with_capacity(capacity);
|
|
|
|
reader.read_to_end(&mut tail)?;
|
|
|
|
file.set_len(0)?;
|
|
file.seek(SeekFrom::Start(0))?;
|
|
file.write_all(&tail)?;
|
|
file.flush()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 {
|
|
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
|
|
.floor()
|
|
.clamp(1.0, max_bytes as f64) as u64;
|
|
|
|
soft_cap_bytes.max(newest_entry_len)
|
|
}
|
|
|
|
/// Asynchronously fetch the history file's *identifier* and current entry count.
|
|
///
|
|
/// The identifier is the file's inode on Unix or creation time on Windows.
|
|
/// The entry count is derived by counting newline bytes in the file. Returns
|
|
/// `(0, 0)` when the file does not exist or its metadata cannot be read. If
|
|
/// metadata succeeds but the file cannot be opened or scanned, returns
|
|
/// `(log_id, 0)` so callers can still detect that a history file exists.
|
|
pub async fn history_metadata(config: &Config) -> (u64, usize) {
|
|
let path = history_filepath(config);
|
|
history_metadata_for_file(&path).await
|
|
}
|
|
|
|
/// Look up a single history entry by file identity and zero-based offset.
|
|
///
|
|
/// Returns `Some(entry)` when the current history file's identifier (inode on
|
|
/// Unix, creation time on Windows) matches `log_id` **and** a valid JSON
|
|
/// record exists at `offset`. Returns `None` on any mismatch, I/O error, or
|
|
/// parse failure, all of which are logged at `warn` level.
|
|
///
|
|
/// This function is synchronous because it acquires a shared advisory file lock
|
|
/// via `File::try_lock_shared`. Callers on an async runtime should wrap it in
|
|
/// `spawn_blocking`.
|
|
pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
|
|
let path = history_filepath(config);
|
|
lookup_history_entry(&path, log_id, offset)
|
|
}
|
|
|
|
/// On Unix systems, ensure the file permissions are `0o600` (rw-------). If the
|
|
/// permissions cannot be changed the error is propagated to the caller.
|
|
#[cfg(unix)]
|
|
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
|
|
let metadata = file.metadata()?;
|
|
let current_mode = metadata.permissions().mode() & 0o777;
|
|
if current_mode != 0o600 {
|
|
let mut perms = metadata.permissions();
|
|
perms.set_mode(0o600);
|
|
let perms_clone = perms.clone();
|
|
let file_clone = file.try_clone()?;
|
|
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
// On Windows, simply succeed.
|
|
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn history_metadata_for_file(path: &Path) -> (u64, usize) {
|
|
let log_id = match fs::metadata(path).await {
|
|
Ok(metadata) => history_log_id(&metadata).unwrap_or(0),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
|
|
Err(_) => return (0, 0),
|
|
};
|
|
|
|
// Open the file.
|
|
let mut file = match fs::File::open(path).await {
|
|
Ok(f) => f,
|
|
Err(_) => return (log_id, 0),
|
|
};
|
|
|
|
// Count newline bytes.
|
|
let mut buf = [0u8; 8192];
|
|
let mut count = 0usize;
|
|
loop {
|
|
match file.read(&mut buf).await {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
count += buf[..n].iter().filter(|&&b| b == b'\n').count();
|
|
}
|
|
Err(_) => return (log_id, 0),
|
|
}
|
|
}
|
|
|
|
(log_id, count)
|
|
}
|
|
|
|
fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option<HistoryEntry> {
|
|
use std::io::BufRead;
|
|
use std::io::BufReader;
|
|
|
|
let file: File = match OpenOptions::new().read(true).open(path) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to open history file");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let metadata = match file.metadata() {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to stat history file");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let current_log_id = history_log_id(&metadata)?;
|
|
|
|
if log_id != 0 && current_log_id != log_id {
|
|
return None;
|
|
}
|
|
|
|
// Open & lock file for reading using a shared lock.
|
|
// Retry a few times to avoid indefinite blocking.
|
|
for _ in 0..MAX_RETRIES {
|
|
let lock_result = file.try_lock_shared();
|
|
|
|
match lock_result {
|
|
Ok(()) => {
|
|
let reader = BufReader::new(&file);
|
|
for (idx, line_res) in reader.lines().enumerate() {
|
|
let line = match line_res {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to read line from history file");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
if idx == offset {
|
|
match serde_json::from_str::<HistoryEntry>(&line) {
|
|
Ok(entry) => return Some(entry),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to parse history entry");
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Not found at requested offset.
|
|
return None;
|
|
}
|
|
Err(std::fs::TryLockError::WouldBlock) => {
|
|
std::thread::sleep(RETRY_SLEEP);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to acquire shared lock on history file");
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
Some(metadata.ino())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
|
|
use std::os::windows::fs::MetadataExt;
|
|
Some(metadata.creation_time())
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
fn history_log_id(_metadata: &std::fs::Metadata) -> Option<u64> {
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "message_history_tests.rs"]
|
|
mod tests;
|