Compare commits

..
5 Commits
Author SHA1 Message Date
yynxxxxxandzjjszmx 4c2cb51c59 Release v0.3.23
Publish the verified large-session streaming and folder-browser directory
identity changes with the original contributor and maintainer attribution.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:31:18 +08:00
yynxxxxxandzjjszmx b796b4d2cd fix(sessions): respect filesystem directory identities in folder browser
Complete PR #52 directory identity handling, folder navigation and scoped
pin storage while retaining its original folder browser contribution.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:24:49 +08:00
zjjszmx 16f64e3982 feat: add virtualized session folder browser 2026-10-01 14:24:27 +08:00
yynxxxxxandzjjszmx 4772287fe1 fix(sessions): stream large logs and preserve sync consistency
Complete the large-rollout work discussed in PR #51 with bounded-memory
streaming, private disk snapshots, guarded index commits and safe rollback.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:24:27 +08:00
sky 163285cf32 docs: record verified v0.3.22 release [skip ci] 2026-09-30 19:17:31 +08:00
21 changed files with 5378 additions and 480 deletions
+7
View File
@@ -2,6 +2,13 @@
记录 Codex-X 的新功能、体验改进和问题修复。
## [v0.3.23] - 2026-10-01
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
## [v0.3.22] - 2026-09-30
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.22",
"version": "0.3.23",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+2 -1
View File
@@ -492,7 +492,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.22"
version = "0.3.23"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -519,6 +519,7 @@ dependencies = [
"tokio",
"toml_edit 0.22.27",
"ureq",
"windows-sys 0.61.2",
"zip 2.4.2",
"zstd",
]
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.22"
version = "0.3.23"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -46,3 +46,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
flate2 = "1"
zstd = "0.13"
tempfile = "3.27"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
+843 -64
View File
@@ -1,24 +1,29 @@
use super::app_server::delete_sessions_via_codex_app_server;
use super::rollout_stream::{for_each_jsonl_record, record_matches_top_level_strings};
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
SqliteThreadIndexState,
};
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
use super::types::SessionSyncStatus;
use crate::error::{CodexxError, Result};
use crate::file_io::{io_err, write_text};
use crate::file_io::io_err;
use crate::resolve_codex_dir;
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{Duration, SystemTime};
use tempfile::NamedTempFile;
#[cfg(test)]
use super::storage::hash_rollout_file;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -481,88 +486,447 @@ fn selected_rollout_paths(
Ok(paths)
}
const CLEANUP_IO_BUFFER_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
struct CleanupFileStamp {
len: u64,
modified: Option<SystemTime>,
created: Option<SystemTime>,
unix_identity: Option<(u64, u64)>,
}
impl CleanupFileStamp {
fn from_metadata(metadata: &fs::Metadata) -> Self {
#[cfg(unix)]
let unix_identity = {
use std::os::unix::fs::MetadataExt;
Some((metadata.dev(), metadata.ino()))
};
#[cfg(not(unix))]
let unix_identity = None;
Self {
len: metadata.len(),
modified: metadata.modified().ok(),
created: metadata.created().ok(),
unix_identity,
}
}
}
struct CleanupHashingReader<'a> {
source: &'a mut fs::File,
backup: Option<&'a mut fs::File>,
digest: &'a mut Sha256,
consumed: u64,
}
impl Read for CleanupHashingReader<'_> {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
if let Some(backup) = self.backup.as_mut() {
backup.write_all(&bytes[..count])?;
}
self.digest.update(&bytes[..count]);
self.consumed += count as u64;
Ok(count)
}
}
fn copy_cleanup_bytes(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<u64> {
let mut bytes = [0u8; CLEANUP_IO_BUFFER_BYTES];
let mut copied = 0u64;
loop {
let count = reader.read(&mut bytes)?;
if count == 0 {
return Ok(copied);
}
writer.write_all(&bytes[..count])?;
copied += count as u64;
}
}
fn filter_cleanup_records<R: Read, W: Write>(
reader: R,
writer: &mut W,
path: &Path,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> Result<usize> {
let mut removed = 0usize;
for_each_jsonl_record(reader, |record, _| {
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let matches = if session_ids.is_empty() {
false
} else {
match record_matches_top_level_strings(record, id_keys, session_ids, 128) {
Ok(matches) => matches,
Err(error @ CodexxError::Io { .. }) => return Err(error),
// Invalid JSON is preserved exactly as in the previous filter.
Err(_) => false,
}
};
if matches {
removed += 1;
} else {
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
copy_cleanup_bytes(record, writer).map_err(|error| io_err(path, error))?;
}
Ok(())
})?;
Ok(removed)
}
struct PreparedJsonlCleanup {
output: NamedTempFile,
original_backup: Option<NamedTempFile>,
original_stamp: CleanupFileStamp,
original_hash: [u8; 32],
removed: usize,
}
fn cleanup_source_changed(path: &Path) -> CodexxError {
CodexxError::Config(format!(
"会话索引或历史记录在清理期间发生变化,已取消替换: {}",
path.display()
))
}
fn hash_open_cleanup_file(path: &Path, source: &fs::File) -> Result<[u8; 32]> {
// Read the existing handle: an exclusive history lock can reject a second
// handle's read on Windows, even when it is opened by this same process.
let mut reader = source;
reader
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
loop {
let count = reader
.read(&mut buffer)
.map_err(|error| io_err(path, error))?;
if count == 0 {
return Ok(digest.finalize().into());
}
digest.update(&buffer[..count]);
}
}
fn ensure_cleanup_source_unchanged(
path: &Path,
source: &fs::File,
stamp: &CleanupFileStamp,
hash: &[u8; 32],
) -> Result<()> {
let source_stamp = || -> Result<CleanupFileStamp> {
source
.metadata()
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
.map_err(|error| io_err(path, error))
};
let path_stamp = || -> Result<CleanupFileStamp> {
fs::metadata(path)
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
.map_err(|error| io_err(path, error))
};
if &source_stamp()? != stamp || &path_stamp()? != stamp {
return Err(cleanup_source_changed(path));
}
if &hash_open_cleanup_file(path, source)? != hash
|| &source_stamp()? != stamp
|| &path_stamp()? != stamp
{
return Err(cleanup_source_changed(path));
}
Ok(())
}
fn prepare_jsonl_cleanup(
path: &Path,
source: &mut fs::File,
id_keys: &[&str],
session_ids: &HashSet<String>,
retain_backup: bool,
) -> Result<PreparedJsonlCleanup> {
let metadata = source.metadata().map_err(|error| io_err(path, error))?;
if !metadata.is_file() {
return Err(CodexxError::Config(format!(
"会话日志不是普通文件: {}",
path.display()
)));
}
let original_stamp = CleanupFileStamp::from_metadata(&metadata);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut output = NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?;
let mut original_backup = if retain_backup {
Some(NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?)
} else {
None
};
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut reader = CleanupHashingReader {
source: &mut *source,
backup: original_backup.as_mut().map(NamedTempFile::as_file_mut),
digest: &mut digest,
consumed: 0,
};
let removed = filter_cleanup_records(
&mut reader,
output.as_file_mut(),
path,
id_keys,
session_ids,
)?;
let consumed = reader.consumed;
drop(reader);
if consumed != original_stamp.len
|| CleanupFileStamp::from_metadata(&source.metadata().map_err(|error| io_err(path, error))?)
!= original_stamp
{
return Err(cleanup_source_changed(path));
}
output
.as_file()
.sync_all()
.map_err(|error| io_err(path, error))?;
if let Some(backup) = original_backup.as_ref() {
backup
.as_file()
.sync_all()
.map_err(|error| io_err(path, error))?;
}
let original_hash = digest.finalize().into();
ensure_cleanup_source_unchanged(path, source, &original_stamp, &original_hash)?;
Ok(PreparedJsonlCleanup {
output,
original_backup,
original_stamp,
original_hash,
removed,
})
}
fn preflight_session_jsonl_cleanup(codex_dir: &Path) -> Result<()> {
// Framing and staging use fixed memory even for a very large valid history.
// Read/write failures still occur before the irreversible deletion API.
for filename in ["session_index.jsonl", "history.jsonl"] {
let path = codex_dir.join(filename);
let mut source = match fs::File::open(&path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_err(&path, error)),
};
prepare_jsonl_cleanup(&path, &mut source, &[], &HashSet::new(), false)?;
}
Ok(())
}
fn commit_index_cleanup(
path: &Path,
source: &fs::File,
prepared: PreparedJsonlCleanup,
) -> Result<usize> {
if prepared.removed == 0 {
return Ok(0);
}
ensure_cleanup_source_unchanged(
path,
source,
&prepared.original_stamp,
&prepared.original_hash,
)?;
let permissions = source
.metadata()
.map_err(|error| io_err(path, error))?
.permissions();
prepared
.output
.as_file()
.set_permissions(permissions)
.map_err(|error| io_err(path, error))?;
prepared
.output
.persist(path)
.map_err(|error| io_err(path, error.error))?;
Ok(prepared.removed)
}
fn remove_jsonl_session_entries(
path: &Path,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> Result<usize> {
if !path.exists() {
return Ok(0);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let (next, removed) = filter_jsonl_session_entries(&text, id_keys, session_ids);
if removed > 0 {
write_text(path, &next)?;
}
Ok(removed)
}
fn filter_jsonl_session_entries(
text: &str,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> (String, usize) {
let mut next = String::with_capacity(text.len());
let mut removed = 0usize;
for segment in text.split_inclusive('\n') {
let (line, ending) = split_line_ending(segment);
let matches = serde_json::from_str::<Value>(line)
.ok()
.and_then(|value| {
id_keys.iter().find_map(|key| {
value
.get(*key)
.and_then(Value::as_str)
.map(ToString::to_string)
})
})
.is_some_and(|id| session_ids.contains(&id));
if matches {
removed += 1;
} else {
next.push_str(line);
next.push_str(ending);
}
}
(next, removed)
let mut source = match fs::File::open(path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(io_err(path, error)),
};
let prepared = prepare_jsonl_cleanup(path, &mut source, id_keys, session_ids, false)?;
commit_index_cleanup(path, &source, prepared)
}
fn remove_session_index_entries(codex_dir: &Path, session_ids: &HashSet<String>) -> Result<usize> {
remove_jsonl_session_entries(&codex_dir.join("session_index.jsonl"), &["id"], session_ids)
}
fn restore_history_backup(
path: &Path,
source: &mut fs::File,
backup: &mut fs::File,
original_len: u64,
original_hash: &[u8; 32],
) -> Result<()> {
backup
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
if copy_cleanup_bytes(backup, source).map_err(|error| io_err(path, error))? != original_len {
return Err(CodexxError::Config(
"历史记录备份不完整,无法安全回滚".into(),
));
}
// Filtering never grows the file. Replaying the complete backup restores its
// original length after a failed truncate, and does not truncate a later
// append beyond the original EOF from a writer that ignored the lock.
source.sync_all().map_err(|error| io_err(path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
let mut remaining = original_len;
while remaining > 0 {
let allowed = buffer.len().min(remaining as usize);
let count = source
.read(&mut buffer[..allowed])
.map_err(|error| io_err(path, error))?;
if count == 0 {
return Err(CodexxError::Config("历史记录回滚后长度不足".into()));
}
digest.update(&buffer[..count]);
remaining -= count as u64;
}
let restored: [u8; 32] = digest.finalize().into();
if &restored != original_hash {
return Err(CodexxError::Config("历史记录回滚校验失败".into()));
}
Ok(())
}
fn commit_history_cleanup_with_writer<F>(
path: &Path,
source: &mut fs::File,
mut prepared: PreparedJsonlCleanup,
write_output: F,
) -> Result<usize>
where
F: FnOnce(&mut fs::File, &mut fs::File, u64, u64) -> Result<()>,
{
if prepared.removed == 0 {
return Ok(0);
}
ensure_cleanup_source_unchanged(
path,
source,
&prepared.original_stamp,
&prepared.original_hash,
)?;
let output_len = prepared
.output
.as_file()
.metadata()
.map_err(|error| io_err(path, error))?
.len();
if output_len > prepared.original_stamp.len {
return Err(CodexxError::Config(
"历史记录过滤结果异常,未写入原文件".into(),
));
}
let mut backup = prepared
.original_backup
.take()
.ok_or_else(|| CodexxError::Config("历史记录缺少完整磁盘备份,未写入原文件".into()))?;
if let Err(error) = write_output(
source,
prepared.output.as_file_mut(),
output_len,
prepared.original_stamp.len,
) {
if let Err(restore_error) = restore_history_backup(
path,
source,
backup.as_file_mut(),
prepared.original_stamp.len,
&prepared.original_hash,
) {
return match backup.keep() {
Ok((_, retained)) => Err(CodexxError::Config(format!(
"{error};历史记录回滚失败:{restore_error};已保留私有备份:{}",
retained.display()
))),
Err(keep_error) => Err(CodexxError::Config(format!(
"{error};历史记录回滚失败:{restore_error};保留备份失败:{keep_error}"
))),
};
}
return Err(error);
}
Ok(prepared.removed)
}
fn remove_session_history_entries(
codex_dir: &Path,
session_ids: &HashSet<String>,
) -> Result<usize> {
let path = codex_dir.join("history.jsonl");
if !path.exists() {
return Ok(0);
}
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
let mut file = match fs::OpenOptions::new().read(true).write(true).open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(io_err(&path, error)),
};
file.try_lock().map_err(|error| {
CodexxError::Config(format!(
"历史记录正在被其他 Codex 进程使用,请关闭相关 Codex 窗口或 CLI 后重试: {error}"
))
})?;
let result = (|| -> Result<usize> {
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| io_err(&path, e))?;
let (next, removed) = filter_jsonl_session_entries(&text, &["session_id"], session_ids);
if removed > 0 {
file.set_len(0).map_err(|e| io_err(&path, e))?;
file.seek(SeekFrom::Start(0))
.map_err(|e| io_err(&path, e))?;
file.write_all(next.as_bytes())
.map_err(|e| io_err(&path, e))?;
file.sync_all().map_err(|e| io_err(&path, e))?;
}
Ok(removed)
let prepared = prepare_jsonl_cleanup(&path, &mut file, &["session_id"], session_ids, true)?;
commit_history_cleanup_with_writer(
&path,
&mut file,
prepared,
|source, output, output_len, original_len| {
output
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(&path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(&path, error))?;
copy_cleanup_bytes(output, source).map_err(|error| io_err(&path, error))?;
if source
.metadata()
.map_err(|error| io_err(&path, error))?
.len()
!= original_len
{
return Err(cleanup_source_changed(&path));
}
source
.set_len(output_len)
.map_err(|error| io_err(&path, error))?;
source.sync_all().map_err(|error| io_err(&path, error))?;
Ok(())
},
)
})();
let _ = file.unlock();
result
@@ -787,6 +1151,7 @@ pub(crate) fn hard_delete_sessions_locally(
let relationship_sources = relationship_database_sources(&discovery, roots)?;
let session_ids = session_ids_with_descendants(&relationship_sources, roots)?;
let rollout_paths = selected_rollout_paths(codex_dir, &discovery.thread_paths, &session_ids)?;
preflight_session_jsonl_cleanup(codex_dir)?;
Ok(delete_exact_session_ids_locally(
codex_dir,
&discovery.related_paths,
@@ -834,6 +1199,7 @@ pub(crate) fn delete_codex_sessions_inner(
// make the deletion irreversible.
let expected_rollout_paths =
selected_rollout_paths(&codex_dir, &discovery.thread_paths, &expected_ids)?;
preflight_session_jsonl_cleanup(&codex_dir)?;
let mut counts = LocalSessionDeleteCounts::default();
let mut failed_roots = Vec::new();
@@ -1121,4 +1487,417 @@ mod tests {
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn unreadable_cleanup_logs_cancel_native_and_local_deletion_before_any_mutation() {
for filename in ["session_index.jsonl", "history.jsonl"] {
let codex_dir = temp_codex_dir();
let id = "019f6000-0000-7000-8000-000000000371";
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id);
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
let rollout_text = format!(
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n"
);
fs::write(&rollout, &rollout_text).unwrap();
for (name, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
fs::write(codex_dir.join(name), format!("{{\"{key}\":\"{id}\"}}\n")).unwrap();
}
let unreadable = codex_dir.join(filename);
fs::remove_file(&unreadable).unwrap();
fs::create_dir(&unreadable).unwrap();
let database_before = fs::read(&database).unwrap();
let untouched_name = if filename == "history.jsonl" {
"session_index.jsonl"
} else {
"history.jsonl"
};
let untouched_path = codex_dir.join(untouched_name);
let untouched_before = fs::read(&untouched_path).unwrap();
let local_error = hard_delete_sessions_locally(&codex_dir, &[id.to_string()])
.expect_err("local deletion must reject an unreadable cleanup log");
assert!(local_error.to_string().contains(filename));
let native_error = delete_codex_sessions_inner(SessionDeleteInput {
config_dir: Some(codex_dir.display().to_string()),
session_ids: vec![id.to_string()],
})
.expect_err("native deletion must stop at preflight before starting Codex");
assert!(native_error.to_string().contains(filename));
assert_eq!(fs::read(&rollout).unwrap(), rollout_text.as_bytes());
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&untouched_path).unwrap(), untouched_before);
assert!(unreadable.is_dir());
let present =
active_session_ids_present(&[database], &HashSet::from([id.to_string()])).unwrap();
assert!(present.contains(id));
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn large_cleanup_logs_stream_without_a_total_size_limit() {
for filename in ["session_index.jsonl", "history.jsonl"] {
let codex_dir = temp_codex_dir();
let id = "019f6000-0000-7000-8000-000000000381";
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id);
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
fs::write(&rollout, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
let path = codex_dir.join(filename);
let key = if filename == "history.jsonl" {
"session_id"
} else {
"id"
};
let remove_large_record = filename == "history.jsonl";
let mut file = fs::File::create(&path).unwrap();
let large_id = if remove_large_record { id } else { "kept" };
let prefix = format!("{{\"{key}\":\"{large_id}\",\"text\":\"");
let mut expected = Sha256::new();
file.write_all(prefix.as_bytes()).unwrap();
if !remove_large_record {
expected.update(prefix.as_bytes());
}
let bytes = [b'x'; CLEANUP_IO_BUFFER_BYTES];
for _ in 0..(33 * 1024 * 1024 / CLEANUP_IO_BUFFER_BYTES) {
file.write_all(&bytes).unwrap();
if !remove_large_record {
expected.update(bytes);
}
}
file.write_all(b"\"}\r\n").unwrap();
if !remove_large_record {
expected.update(b"\"}\r\n");
file.write_all(format!("{{\"{key}\":\"{id}\"}}\n").as_bytes())
.unwrap();
}
let tail = format!("not-json\r\n{{\"{key}\":\"{id}\",\"{key}\":\"kept-last\"}}\n{{\"{key}\":\"kept-final\"}}");
file.write_all(tail.as_bytes()).unwrap();
expected.update(tail.as_bytes());
drop(file);
assert!(fs::metadata(&path).unwrap().len() > 32 * 1024 * 1024);
let other_name = if filename == "history.jsonl" {
"session_index.jsonl"
} else {
"history.jsonl"
};
let other_key = if key == "id" { "session_id" } else { "id" };
fs::write(
codex_dir.join(other_name),
format!("{{\"{other_key}\":\"{id}\"}}\r\n{{\"{other_key}\":\"untouched\"}}"),
)
.unwrap();
preflight_session_jsonl_cleanup(&codex_dir)
.expect("large valid logs must pass streaming preflight");
let result = hard_delete_sessions_locally(&codex_dir, &[id.to_string()]).unwrap();
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert!(!rollout.exists());
assert!(
!active_session_ids_present(&[database], &HashSet::from([id.to_string()]))
.unwrap()
.contains(id)
);
let expected_hash: [u8; 32] = expected.finalize().into();
assert_eq!(hash_rollout_file(&path).unwrap(), expected_hash);
assert_eq!(
fs::read(codex_dir.join(other_name)).unwrap(),
format!("{{\"{other_key}\":\"untouched\"}}").as_bytes()
);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn streaming_cleanup_preserves_missing_files_and_normal_jsonl_filtering() {
let codex_dir = temp_codex_dir();
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
assert!(!codex_dir.join("session_index.jsonl").exists());
assert!(!codex_dir.join("history.jsonl").exists());
let ids = HashSet::from(["selected".to_string()]);
for (filename, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
fs::write(
codex_dir.join(filename),
format!("{{\"{key}\":\"selected\"}}\r\nnot-json\r\n{{\"{key}\":\"kept\"}}"),
)
.unwrap();
}
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
assert_eq!(remove_session_index_entries(&codex_dir, &ids).unwrap(), 1);
assert_eq!(remove_session_history_entries(&codex_dir, &ids).unwrap(), 1);
for (filename, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
assert_eq!(
fs::read_to_string(codex_dir.join(filename)).unwrap(),
format!("not-json\r\n{{\"{key}\":\"kept\"}}")
);
}
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn streaming_cleanup_only_matches_valid_top_level_ids_and_preserves_other_bytes() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
let kept = b"not-json\r\n{\"nested\":{\"id\":\"selected\"},\"id\":\"kept\"}\n{\"id\":\"selected\",\"id\":null}\r\n\xff\n";
let mut original = kept.to_vec();
original.extend_from_slice(b"{\"id\":\"selected\"}\r\n");
original.extend_from_slice(b"{\"id\":\"last-kept\"}");
fs::write(&path, original).unwrap();
assert_eq!(
remove_session_index_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
.unwrap(),
1
);
let mut expected = kept.to_vec();
expected.extend_from_slice(b"{\"id\":\"last-kept\"}");
assert_eq!(fs::read(path).unwrap(), expected);
fs::remove_dir_all(codex_dir).unwrap();
}
struct FailingCleanupRead<R> {
inner: R,
remaining: usize,
}
impl<R: Read> Read for FailingCleanupRead<R> {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("injected cleanup read failure"));
}
let allowed = bytes.len().min(self.remaining);
let count = self.inner.read(&mut bytes[..allowed])?;
self.remaining -= count;
Ok(count)
}
}
struct FailingCleanupWrite<W> {
inner: W,
remaining: usize,
}
impl<W: Write> Write for FailingCleanupWrite<W> {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("injected cleanup write failure"));
}
let count = self
.inner
.write(&bytes[..bytes.len().min(self.remaining)])?;
self.remaining -= count;
Ok(count)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[test]
fn streaming_cleanup_read_or_staging_write_failure_preserves_the_source() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
let original = b"{\"id\":\"kept\",\"text\":\"retained\"}\r\n{\"id\":\"selected\"}\n";
fs::write(&path, original).unwrap();
let ids = HashSet::from(["selected".to_string()]);
let mut target = NamedTempFile::new_in(&codex_dir).unwrap();
let error = filter_cleanup_records(
FailingCleanupRead {
inner: fs::File::open(&path).unwrap(),
remaining: 7,
},
target.as_file_mut(),
&path,
&["id"],
&ids,
)
.expect_err("read errors must propagate, not become a malformed record");
assert!(matches!(error, CodexxError::Io { .. }));
assert_eq!(fs::read(&path).unwrap(), original);
let mut writer = FailingCleanupWrite {
inner: target.as_file_mut(),
remaining: 3,
};
let error = filter_cleanup_records(
fs::File::open(&path).unwrap(),
&mut writer,
&path,
&["id"],
&ids,
)
.expect_err("a failed private output must never be installed");
assert!(matches!(error, CodexxError::Io { .. }));
assert_eq!(fs::read(&path).unwrap(), original);
drop(target);
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn staged_cleanup_detects_growth_before_index_or_history_commit() {
for history in [false, true] {
let codex_dir = temp_codex_dir();
let path = codex_dir.join(if history {
"history.jsonl"
} else {
"session_index.jsonl"
});
let key = if history { "session_id" } else { "id" };
fs::write(
&path,
format!("{{\"{key}\":\"selected\"}}\n{{\"{key}\":\"kept\"}}\n"),
)
.unwrap();
let mut source = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
let prepared = prepare_jsonl_cleanup(
&path,
&mut source,
&[key],
&HashSet::from(["selected".to_string()]),
history,
)
.unwrap();
let mut external = fs::OpenOptions::new().append(true).open(&path).unwrap();
external
.write_all(format!("{{\"{key}\":\"new-append\"}}\n").as_bytes())
.unwrap();
external.sync_all().unwrap();
drop(external);
let grown = fs::read(&path).unwrap();
let result = if history {
commit_history_cleanup_with_writer(&path, &mut source, prepared, |_, _, _, _| {
panic!("growth must be rejected before writing history")
})
} else {
commit_index_cleanup(&path, &source, prepared)
};
assert!(result
.expect_err("a stale staged result must not replace appended data")
.to_string()
.contains("发生变化"));
assert_eq!(fs::read(&path).unwrap(), grown);
drop(source);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn history_commit_write_failure_restores_backup_and_keeps_its_lock_and_inode() {
for fail_after_truncate in [false, true] {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("history.jsonl");
let original = b"{\"session_id\":\"selected\",\"text\":\"private fixture\"}\r\n{\"session_id\":\"kept\"}";
fs::write(&path, original).unwrap();
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
let mut source = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
source.try_lock().unwrap();
let probe = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
assert!(probe.try_lock().is_err());
let prepared = prepare_jsonl_cleanup(
&path,
&mut source,
&["session_id"],
&HashSet::from(["selected".to_string()]),
true,
)
.unwrap();
let error = commit_history_cleanup_with_writer(
&path,
&mut source,
prepared,
|source, output, output_len, _| {
source.seek(SeekFrom::Start(0)).unwrap();
output.seek(SeekFrom::Start(0)).unwrap();
if fail_after_truncate {
copy_cleanup_bytes(output, source).unwrap();
source.set_len(output_len).unwrap();
} else {
source.write_all(b"partial failed write").unwrap();
}
Err(io_err(
&path,
std::io::Error::other("injected commit write/sync failure"),
))
},
)
.expect_err("failed commit must report the failure after restoring the disk backup");
assert!(error.to_string().contains("injected commit"));
let expected_hash: [u8; 32] = Sha256::digest(original).into();
assert_eq!(
hash_open_cleanup_file(&path, &source).unwrap(),
expected_hash
);
assert_eq!(source.metadata().unwrap().len(), original.len() as u64);
assert_eq!(
CleanupFileStamp::from_metadata(&source.metadata().unwrap()).unix_identity,
before.unix_identity
);
assert!(
probe.try_lock().is_err(),
"rollback must not release the live history lock"
);
source.unlock().unwrap();
assert!(probe.try_lock().is_ok());
probe.unlock().unwrap();
drop(probe);
drop(source);
assert_eq!(fs::read(&path).unwrap(), original);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn successful_history_cleanup_keeps_an_already_open_append_handle_usable() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("history.jsonl");
fs::write(
&path,
b"{\"session_id\":\"selected\"}\n{\"session_id\":\"kept\"}\n",
)
.unwrap();
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
let mut old_append_handle = fs::OpenOptions::new()
.read(true)
.append(true)
.open(&path)
.unwrap();
assert_eq!(
remove_session_history_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
.unwrap(),
1
);
old_append_handle.try_lock().unwrap();
old_append_handle
.write_all(b"{\"session_id\":\"later\"}\r\n")
.unwrap();
old_append_handle.unlock().unwrap();
drop(old_append_handle);
let after = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
assert_eq!(after.unix_identity, before.unix_identity);
assert_eq!(
fs::read(&path).unwrap(),
b"{\"session_id\":\"kept\"}\n{\"session_id\":\"later\"}\r\n"
);
fs::remove_dir_all(codex_dir).unwrap();
}
}
@@ -0,0 +1,458 @@
use super::types::SessionPreview;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
#[cfg(unix)]
use std::fs::File;
use std::io;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, PartialEq, Eq)]
struct DirectoryIdentity {
key: String,
birth_verified: bool,
}
fn filesystem_identity(
platform: &[u8],
parts: &[&[u8]],
birth: Option<SystemTime>,
) -> DirectoryIdentity {
let mut digest = Sha256::new();
digest.update(b"codex-x/session-directory-identity/v1\0");
digest.update(platform);
digest.update(b"\0");
for part in parts {
digest.update(part);
}
if let Some(birth) = birth {
digest.update(b"\0birth\0");
let (sign, age) = match birth.duration_since(UNIX_EPOCH) {
Ok(age) => (b'+', age),
Err(before_epoch) => (b'-', before_epoch.duration()),
};
digest.update([sign]);
digest.update(age.as_secs().to_le_bytes());
digest.update(age.subsec_nanos().to_le_bytes());
} else {
digest.update(b"\0no-birth\0");
}
DirectoryIdentity {
key: format!("filesystem:{:x}", digest.finalize()),
birth_verified: birth.is_some(),
}
}
#[cfg(any(unix, test))]
fn unix_directory_identity(
device: u64,
inode: u64,
birth: Option<SystemTime>,
) -> Option<DirectoryIdentity> {
// Some virtual or unsupported filesystems expose no usable inode number.
if inode == 0 {
return None;
}
Some(filesystem_identity(
b"unix",
&[&device.to_le_bytes(), &inode.to_le_bytes()],
birth,
))
}
#[cfg(any(windows, test))]
fn windows_directory_identity(
volume: u64,
identifier: &[u8; 16],
birth: Option<SystemTime>,
) -> Option<DirectoryIdentity> {
// The complete 128-bit ID is needed for ReFS; the old 64-bit file index
// cannot safely distinguish all directories on that filesystem.
if identifier.iter().all(|byte| *byte == 0) {
return None;
}
Some(filesystem_identity(
b"windows",
&[&volume.to_le_bytes(), identifier],
birth,
))
}
#[cfg(unix)]
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
use std::os::unix::fs::MetadataExt;
let handle = File::open(path)?;
let metadata = handle.metadata()?;
if !metadata.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a directory",
));
}
unix_directory_identity(metadata.dev(), metadata.ino(), metadata.created().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "directory has no stable inode"))
}
#[cfg(windows)]
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
use std::fs::OpenOptions;
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_INFO,
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
// READ_ATTRIBUTES plus BACKUP_SEMANTICS opens directories without requiring
// permission to list them. No content is read and no filesystem state is changed.
let handle = OpenOptions::new()
.read(true)
.access_mode(FILE_READ_ATTRIBUTES)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(path)?;
let metadata = handle.metadata()?;
if !metadata.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a directory",
));
}
let mut info = FILE_ID_INFO::default();
// The owned read-only handle stays alive while Windows fills this exact
// FILE_ID_INFO buffer. Unsupported filesystems return an error and fall back.
let succeeded = unsafe {
GetFileInformationByHandleEx(
handle.as_raw_handle(),
FileIdInfo,
(&mut info as *mut FILE_ID_INFO).cast(),
std::mem::size_of::<FILE_ID_INFO>() as u32,
)
};
if succeeded == 0 {
return Err(io::Error::last_os_error());
}
windows_directory_identity(
info.VolumeSerialNumber,
&info.FileId.Identifier,
metadata.created().ok(),
)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::Unsupported,
"directory has no stable file ID",
)
})
}
#[cfg(not(any(unix, windows)))]
fn native_directory_identity(_path: &Path) -> io::Result<DirectoryIdentity> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"directory identity is unavailable",
))
}
fn unresolved_directory_identity(path: &str) -> DirectoryIdentity {
DirectoryIdentity {
key: format!("path:{path}"),
birth_verified: false,
}
}
fn directory_lookup(path: &str) -> DirectoryIdentity {
let filesystem_path = Path::new(path);
// Historical relative cwd values cannot be resolved against this app's
// process cwd. Check the path kind before opening, including FIFO/device
// values that must never be treated as a workspace directory.
if !filesystem_path.is_absolute()
|| !std::fs::metadata(filesystem_path).is_ok_and(|metadata| metadata.is_dir())
{
return unresolved_directory_identity(path);
}
native_directory_identity(filesystem_path)
.unwrap_or_else(|_| unresolved_directory_identity(path))
}
fn directory_identity(path: &str) -> String {
directory_lookup(path).key
}
fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
if identity.birth_verified {
identity.key.clone()
} else {
format!("path:{path}")
}
}
/// Resolve the paths represented by this status only. The raw spelling remains
/// the map key. Persistent home scope uses native identity only when birth time
/// was verified; otherwise it conservatively follows the original home path.
pub(super) fn session_directory_identities(
codex_dir: &Path,
sessions: &[SessionPreview],
) -> (HashMap<String, String>, String) {
let mut identities = HashMap::new();
let home = codex_dir.display().to_string();
let home_identity = directory_lookup(&home);
let pin_scope = pin_scope_key(&home, &home_identity);
identities.insert(home.clone(), home_identity.key);
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
identities
.entry(path.to_string())
.or_insert_with(|| directory_identity(path));
}
(identities, pin_scope)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
struct TestDirectory {
path: PathBuf,
_directory: tempfile::TempDir,
}
impl TestDirectory {
fn new() -> Self {
let mut builder = tempfile::Builder::new();
builder.prefix("codex-x-directory-identity-");
let directory = match std::env::var_os("CODEXX_TEST_CASE_DIRECTORY_ROOT") {
Some(root) => builder.tempdir_in(root),
None => builder.tempdir(),
}
.expect("create temporary directory identity fixture");
Self {
path: directory.path().to_path_buf(),
_directory: directory,
}
}
}
fn preview(id: &str, cwd: Option<String>) -> SessionPreview {
SessionPreview {
id: id.to_string(),
title: id.to_string(),
cwd,
model_provider: None,
model: None,
rollout_path: None,
updated_at_ms: None,
archived: false,
has_user_event: false,
is_subagent: false,
needs_sync: false,
}
}
#[test]
fn existing_directory_and_lexical_alias_have_the_same_stable_identity() {
let fixture = TestDirectory::new();
let folder = fixture.path.join("Workspace");
fs::create_dir(&folder).unwrap();
let direct = directory_identity(&folder.display().to_string());
let alias = directory_identity(&folder.join(".").display().to_string());
assert!(direct.starts_with("filesystem:"));
assert_eq!(direct, alias);
assert_eq!(direct, directory_identity(&folder.display().to_string()));
}
#[cfg(unix)]
#[test]
fn symbolic_directory_alias_resolves_to_the_same_identity() {
let fixture = TestDirectory::new();
let folder = fixture.path.join("Workspace");
let alias = fixture.path.join("WorkspaceAlias");
fs::create_dir(&folder).unwrap();
std::os::unix::fs::symlink(&folder, &alias).unwrap();
assert_eq!(
directory_identity(&folder.display().to_string()),
directory_identity(&alias.display().to_string())
);
}
#[test]
fn case_only_names_follow_the_actual_fixture_filesystem() {
let fixture = TestDirectory::new();
let upper = fixture.path.join("CaseDirectory");
let lower = fixture.path.join("casedirectory");
fs::create_dir(&upper).unwrap();
let separate_directories = match fs::create_dir(&lower) {
Ok(()) => true,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
Err(error) => panic!("create case-only directory fixture: {error}"),
};
let upper_key = directory_identity(&upper.display().to_string());
let lower_key = directory_identity(&lower.display().to_string());
assert!(upper_key.starts_with("filesystem:"));
assert!(lower_key.starts_with("filesystem:"));
if separate_directories {
assert_ne!(
upper_key, lower_key,
"case-sensitive directories must remain distinct"
);
} else {
assert_eq!(
upper_key, lower_key,
"case aliases of the same directory must match"
);
}
}
#[test]
fn relative_paths_never_borrow_identity_from_the_application_working_directory() {
for raw in [".", "./", "src", "foo", "C:Project", " relative\\Name "] {
assert_eq!(directory_identity(raw), format!("path:{raw}"));
}
}
#[test]
fn missing_and_nondirectory_paths_keep_the_complete_original_spelling() {
let fixture = TestDirectory::new();
for suffix in ["Missing", "missing", "Missing\\Name ", " missing "] {
let raw = fixture.path.join(suffix).display().to_string();
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
}
let file = fixture.path.join("a-file");
fs::write(&file, b"read-only identity lookup must not alter contents").unwrap();
let raw = file.display().to_string();
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
assert_eq!(
fs::read(&file).unwrap(),
b"read-only identity lookup must not alter contents"
);
}
#[cfg(unix)]
#[test]
fn unreadable_directory_falls_back_without_normalizing_its_name() {
use std::os::unix::fs::PermissionsExt;
let fixture = TestDirectory::new();
let directory = fixture.path.join("UnreadableDirectory");
fs::create_dir(&directory).unwrap();
fs::set_permissions(&directory, fs::Permissions::from_mode(0)).unwrap();
let raw = directory.display().to_string();
let readable_by_process = File::open(&directory).is_ok();
let identity = directory_identity(&raw);
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
if !readable_by_process {
assert_eq!(identity, format!("path:{raw}"));
} else {
// A privileged test runner can bypass the permission restriction.
assert!(identity.starts_with("filesystem:"));
}
}
#[test]
fn empty_native_identifiers_are_rejected_and_full_windows_file_ids_are_used() {
assert!(unix_directory_identity(1, 0, None).is_none());
assert!(windows_directory_identity(1, &[0; 16], None).is_none());
let mut first = [0; 16];
first[0] = 7;
let mut second = first;
second[15] = 1;
assert_ne!(
windows_directory_identity(1, &first, None),
windows_directory_identity(1, &second, None),
"ReFS IDs that differ only beyond the old 64-bit index must not merge"
);
}
#[test]
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
use std::time::Duration;
let created = UNIX_EPOCH + Duration::new(1234, 17);
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
assert!(original.birth_verified);
assert_eq!(
original,
unix_directory_identity(1, 9, Some(created)).unwrap()
);
for new_generation in [
UNIX_EPOCH + Duration::new(1234, 18),
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 17),
UNIX_EPOCH - Duration::new(1234, 17),
] {
assert_ne!(
original,
unix_directory_identity(1, 9, Some(new_generation)).unwrap(),
"reused inode numbers from another directory generation must get different keys"
);
}
let file_id = [7; 16];
assert_ne!(
windows_directory_identity(1, &file_id, Some(created)),
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(1)))
);
}
#[test]
fn persistent_pin_scope_requires_verified_birth_and_preserves_raw_fallback_paths() {
let transient = unix_directory_identity(1, 9, None).unwrap();
assert!(transient.key.starts_with("filesystem:"));
assert!(!transient.birth_verified);
for path in ["/Home/Codex", "/Home/codex", "/Home/Codex\\ "] {
assert_eq!(pin_scope_key(path, &transient), format!("path:{path}"));
}
let verified = unix_directory_identity(1, 9, Some(UNIX_EPOCH)).unwrap();
assert_eq!(pin_scope_key("/Home/Codex", &verified), verified.key);
assert_eq!(pin_scope_key("/Home/codex", &verified), verified.key);
}
#[test]
fn changing_directory_contents_does_not_change_birth_identity_or_pin_scope() {
let fixture = TestDirectory::new();
let directory = fixture.path.join("Workspace");
fs::create_dir(&directory).unwrap();
let raw = directory.display().to_string();
let before = directory_lookup(&raw);
fs::write(directory.join("first.txt"), b"first").unwrap();
fs::write(directory.join("first.txt"), b"updated").unwrap();
fs::write(directory.join("second.txt"), b"second").unwrap();
fs::remove_file(directory.join("first.txt")).unwrap();
let after = directory_lookup(&raw);
assert_eq!(
before, after,
"mtime/ctime changes must not replace the directory birth identity"
);
assert_eq!(pin_scope_key(&raw, &before), pin_scope_key(&raw, &after));
}
#[test]
fn status_identities_include_only_its_home_and_loaded_workspaces_and_isolate_homes() {
let fixture = TestDirectory::new();
let first_home = fixture.path.join("CodexHomeA");
let second_home = fixture.path.join("CodexHomeB");
let workspace = fixture.path.join("Project");
let unused = fixture.path.join("Unused");
for directory in [&first_home, &second_home, &workspace, &unused] {
fs::create_dir(directory).unwrap();
}
let config = first_home.join("config.toml");
fs::write(&config, b"model_provider = \"openai\"\n").unwrap();
let cwd = workspace.display().to_string();
let sessions = vec![
preview("first", Some(cwd.clone())),
preview("second", Some(cwd.clone())),
preview("unknown", None),
];
let (first, first_scope) = session_directory_identities(&first_home, &sessions);
let (second, second_scope) = session_directory_identities(&second_home, &sessions);
assert_eq!(first.len(), 2);
assert!(first.contains_key(&first_home.display().to_string()));
assert!(first.contains_key(&cwd));
assert!(!first.contains_key(&unused.display().to_string()));
assert_ne!(
first[&first_home.display().to_string()],
second[&second_home.display().to_string()]
);
assert_ne!(first_scope, second_scope);
assert_eq!(first[&cwd], second[&cwd]);
assert_eq!(fs::read(&config).unwrap(), b"model_provider = \"openai\"\n");
}
}
@@ -2,8 +2,10 @@ mod app_server;
mod backup;
mod catalog;
mod delete;
mod directory_identity;
mod export;
mod global_state;
mod rollout_stream;
mod storage;
mod sync;
mod transaction;
File diff suppressed because it is too large Load Diff
+460 -270
View File
@@ -1,23 +1,39 @@
use super::global_state::normalize_workspace_path;
use super::rollout_stream::{read_first_meta, scan_rollout_stream};
use super::types::{RolloutScan, SessionFileChange, SessionPreview, SqliteScan};
use crate::error::{CodexxError, Result};
use crate::file_io::{
atomic_write, io_err, json_err, parse_toml_document, read_to_string_if_exists,
};
use crate::file_io::{io_err, parse_toml_document, read_to_string_if_exists};
use crate::paths::home_dir;
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
use crate::{config_path, string_value};
use rusqlite::{Connection, OpenFlags};
use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufReader, Read};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use toml_edit::DocumentMut;
/// Digest without retaining log contents, regardless of file or record size.
pub(super) fn hash_rollout_file(path: &Path) -> std::io::Result<[u8; 32]> {
hash_reader(fs::File::open(path)?)
}
fn hash_reader<R: Read>(mut reader: R) -> std::io::Result<[u8; 32]> {
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().into())
}
pub(super) fn current_model_provider(codex_dir: &Path, explicit: Option<String>) -> Result<String> {
if let Some(provider) = explicit
.map(|s| s.trim().to_string())
@@ -183,16 +199,6 @@ fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec
}
}
pub(super) fn split_line_ending(segment: &str) -> (&str, &str) {
if let Some(line) = segment.strip_suffix("\r\n") {
(line, "\r\n")
} else if let Some(line) = segment.strip_suffix('\n') {
(line, "\n")
} else {
(segment, "")
}
}
fn is_locked_io_error(error: &std::io::Error) -> bool {
matches!(error.kind(), std::io::ErrorKind::PermissionDenied)
|| matches!(error.raw_os_error(), Some(32 | 33))
@@ -301,7 +307,17 @@ fn scan_rollouts_with_thread_filter(
.canonicalize()
.ok()
.and_then(|canonical| referenced.get(&canonical));
let identity = match read_rollout_identity(&path) {
let mut rollout_file = match fs::File::open(&path) {
Ok(file) => file,
Err(error) => {
scan.scan_failures.push(format!(
"无法读取会话文件来源信息: {} ({error})",
path.display()
));
continue;
}
};
let identity = match read_rollout_identity_from_reader(&mut rollout_file, &path) {
Ok(identity) => identity,
Err(failure) => {
scan.scan_failures.push(failure);
@@ -329,156 +345,56 @@ fn scan_rollouts_with_thread_filter(
continue;
}
}
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => {
let reason = if is_locked_io_error(&error) {
"会话文件被占用或无权限读取"
} else {
"无法读取会话文件"
};
scan.scan_failures
.push(format!("{reason}: {} ({error})", path.display()));
continue;
}
};
let mut next_text = String::with_capacity(text.len());
let mut rewrite_needed = false;
let mut file_session_meta_count = 0usize;
let mut file_mismatched_session_meta = 0usize;
let mut invalid_json_lines = 0usize;
let mut invalid_session_meta = 0usize;
let mut thread_id = None;
let mut cwd = None;
let mut is_subagent = false;
for segment in text.split_inclusive('\n') {
let (line, line_ending) = split_line_ending(segment);
let mut next_line = line.to_string();
if !line.trim().is_empty() {
if let Ok(mut record) = serde_json::from_str::<Value>(line) {
if record.get("type").and_then(Value::as_str) == Some("session_meta") {
if let Some(payload) =
record.get_mut("payload").and_then(Value::as_object_mut)
{
file_session_meta_count += 1;
if file_session_meta_count == 1 {
thread_id = payload
.get("id")
.and_then(Value::as_str)
.map(ToString::to_string);
cwd = payload
.get("cwd")
.and_then(Value::as_str)
.and_then(normalize_workspace_path);
is_subagent =
payload.get("source").is_some_and(source_value_is_subagent)
|| payload
.get("thread_source")
.and_then(Value::as_str)
.is_some_and(thread_source_is_internal);
}
if payload.get("model_provider").and_then(Value::as_str)
!= Some(target_provider)
{
payload.insert(
"model_provider".to_string(),
Value::String(target_provider.to_string()),
);
next_line = serde_json::to_string(&record)
.map_err(|error| json_err(&path, error))?;
rewrite_needed = true;
file_mismatched_session_meta += 1;
}
} else {
invalid_session_meta += 1;
}
}
} else {
invalid_json_lines += 1;
}
}
next_text.push_str(&next_line);
next_text.push_str(line_ending);
}
if is_subagent && authoritative_identity {
if let Some(id) = thread_id.as_ref() {
if expected_thread_ids.is_none_or(|expected| expected.contains(id)) {
scan.internal_thread_ids.insert(id.clone());
}
}
}
if (exclude_source_marked_subagents && is_subagent)
|| thread_id
.as_ref()
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
if excluded_thread_ids.is_some_and(|excluded| excluded.contains(identity_id))
|| allowed_thread_ids.is_some_and(|allowed| !allowed.contains(identity_id))
{
continue;
}
if invalid_json_lines > 0 {
scan.scan_failures.push(format!(
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
path.display()
));
}
if invalid_session_meta > 0 {
scan.scan_failures.push(format!(
"会话文件包含 {invalid_session_meta} 条无法读取的 session_meta: {}",
path.display()
));
}
if invalid_json_lines > 0 || invalid_session_meta > 0 {
// A broken orphan remains a warning, but must never become a
// provider/catalog candidate merely because one metadata line parsed.
continue;
}
if file_session_meta_count == 0 {
if expected_thread_ids.is_some() {
let streamed = match scan_rollout_stream(&mut rollout_file, &path, target_provider) {
Ok(streamed) => streamed,
Err(error) => {
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件缺少 session_meta: {}",
path.display()
));
}
continue;
}
let Some(thread_id) = thread_id else {
scan.scan_failures.push(format!(
"会话文件的 session_meta 缺少 id: {}",
path.display()
));
continue;
};
if let Some(expected_thread_ids) = expected_thread_ids {
if expected_thread_ids.len() != 1 || !expected_thread_ids.contains(&thread_id) {
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
"会话文件未完成检查或改写: {} ({error})",
path.display()
));
continue;
}
}
if allowed_thread_ids.is_some_and(|allowed| !allowed.contains(&thread_id)) {
};
let thread_id = streamed.first_meta.id.trim().to_string();
// The same open handle is used for identity and complete scanning.
// Still reject a changed first record instead of pairing mismatched snapshots.
if thread_id != identity_id
|| streamed.first_meta.is_internal != identity.payload.is_internal()
{
scan.scan_failures.push(format!(
"会话来源信息在扫描期间发生变化: {}",
path.display()
));
continue;
}
scan.session_meta_count += file_session_meta_count;
scan.session_meta_count += streamed.session_meta_count;
scan.provider_candidate_paths.insert(path.clone());
scan.verified_rollout_hashes
.insert(path.clone(), streamed.original_hash);
scan.thread_ids.insert(thread_id.clone());
if let Some(cwd) = cwd {
if let Some(cwd) = streamed
.first_meta
.cwd
.as_deref()
.and_then(normalize_workspace_path)
{
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
}
if rewrite_needed {
if let Some(snapshot) = streamed.staged {
scan.mismatched_rollouts += 1;
scan.mismatched_session_meta += file_mismatched_session_meta;
scan.mismatched_session_meta += streamed.mismatch_count;
scan.mismatched_thread_ids.insert(thread_id);
scan.changes.push(SessionFileChange {
original_mtime: fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok(),
path,
original_text: text,
next_text,
original_text: String::new(),
next_text: String::new(),
original_mtime: streamed.original_mtime,
streamed: Some(snapshot),
});
}
}
@@ -509,48 +425,135 @@ fn rollout_file_is_open(_path: &Path) -> bool {
false
}
fn ensure_rollout_snapshot_unchanged(path: &Path, expected: &[u8; 32]) -> Result<()> {
if hash_rollout_file(path).map_err(|error| io_err(path, error))? == *expected {
Ok(())
} else {
Err(CodexxError::Config(format!(
"会话文件已变化,已取消写入: {}",
path.display()
)))
}
}
fn atomic_write_rollout<R: Read>(
path: &Path,
mut reader: R,
expected_current: &[u8; 32],
expected_output: &[u8; 32],
) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| CodexxError::Config("会话文件缺少父目录".into()))?;
let permissions = fs::metadata(path)
.map_err(|error| io_err(path, error))?
.permissions();
let mut temporary = tempfile::Builder::new()
.prefix(".codex-x-rollout-")
.tempfile_in(parent)
.map_err(|error| io_err(path, error))?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let count = reader
.read(&mut buffer)
.map_err(|error| io_err(path, error))?;
if count == 0 {
break;
}
temporary
.write_all(&buffer[..count])
.map_err(|error| io_err(path, error))?;
hasher.update(&buffer[..count]);
}
let output_hash: [u8; 32] = hasher.finalize().into();
if output_hash != *expected_output {
return Err(CodexxError::Config(format!(
"会话改写临时快照已变化,已取消写入: {}",
path.display()
)));
}
temporary
.as_file()
.set_permissions(permissions)
.map_err(|error| io_err(path, error))?;
temporary
.as_file()
.sync_all()
.map_err(|error| io_err(path, error))?;
ensure_rollout_snapshot_unchanged(path, expected_current)?;
temporary
.persist(path)
.map_err(|error| io_err(path, error.error))?;
Ok(())
}
fn write_session_change(change: &SessionFileChange, restore: bool) -> Result<()> {
let (current, output) = if restore {
(change.next_hash(), change.original_hash())
} else {
(change.original_hash(), change.next_hash())
};
if let Some(snapshot) = &change.streamed {
let source = if restore {
&snapshot.original_path
} else {
&snapshot.next_path
};
let reader = fs::File::open(source).map_err(|error| io_err(source, error))?;
atomic_write_rollout(&change.path, reader, &current, &output)
} else {
let text = if restore {
&change.original_text
} else {
&change.next_text
};
atomic_write_rollout(&change.path, text.as_bytes(), &current, &output)
}
}
#[cfg(test)]
pub(crate) fn apply_session_changes(
changes: &[SessionFileChange],
) -> Result<(Vec<SessionFileChange>, Vec<PathBuf>)> {
apply_session_changes_with_hook(changes, |_| Ok(()))
}
pub(super) fn apply_session_changes_with_hook<F>(
changes: &[SessionFileChange],
mut hook: F,
) -> Result<(Vec<SessionFileChange>, Vec<PathBuf>)>
where
F: FnMut(usize) -> Result<()>,
{
let mut applied = Vec::new();
let mut skipped = Vec::new();
for change in changes {
if rollout_file_is_open(&change.path) {
skipped.push(change.path.clone());
continue;
}
match fs::read_to_string(&change.path) {
Ok(current) if current == change.original_text => {}
Ok(_) => {
skipped.push(change.path.clone());
continue;
for (index, change) in changes.iter().enumerate() {
let attempt = (|| {
hook(index)?;
if rollout_file_is_open(&change.path) {
return Ok(false);
}
Err(error) if is_locked_io_error(&error) => {
skipped.push(change.path.clone());
continue;
}
Err(error) => {
let original_error = io_err(&change.path, error);
return match restore_session_changes(&applied) {
Ok(()) => Err(original_error),
Err(rollback_error) => Err(CodexxError::Config(format!(
"{original_error};回滚失败:{rollback_error}"
))),
};
}
}
match atomic_write(&change.path, change.next_text.as_bytes()) {
Ok(()) => {
restore_file_mtime(&change.path, change.original_mtime);
applied.push(change.clone());
match hash_rollout_file(&change.path) {
Ok(current) if current == change.original_hash() => {}
Ok(_) => return Ok(false),
Err(error) if is_locked_io_error(&error) => return Ok(false),
Err(error) => return Err(io_err(&change.path, error)),
}
write_session_change(change, false)?;
restore_file_mtime(&change.path, change.original_mtime);
Ok(true)
})();
match attempt {
Ok(true) => applied.push(change.clone()),
Ok(false) => skipped.push(change.path.clone()),
Err(error) => {
return match restore_session_changes(&applied) {
Ok(()) => Err(error),
Err(rollback_error) => Err(CodexxError::Config(format!(
"{error};回滚失败:{rollback_error}"
))),
};
}
}
}
}
@@ -559,18 +562,11 @@ pub(crate) fn apply_session_changes(
pub(crate) fn restore_session_changes(changes: &[SessionFileChange]) -> Result<()> {
let mut failed = 0usize;
for change in changes {
if rollout_file_is_open(&change.path) {
failed += 1;
continue;
}
let unchanged =
fs::read_to_string(&change.path).is_ok_and(|current| current == change.next_text);
if !unchanged {
failed += 1;
continue;
}
if atomic_write(&change.path, change.original_text.as_bytes()).is_err() {
for change in changes.iter().rev() {
if rollout_file_is_open(&change.path)
|| !hash_rollout_file(&change.path).is_ok_and(|hash| hash == change.next_hash())
|| write_session_change(change, true).is_err()
{
failed += 1;
continue;
}
@@ -1199,74 +1195,11 @@ pub(crate) fn source_text_is_subagent(source: &str) -> bool {
#[derive(Default)]
struct InternalSource(bool);
impl<'de> Deserialize<'de> for InternalSource {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct SourceVisitor;
impl<'de> Visitor<'de> for SourceVisitor {
type Value = InternalSource;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("session source")
}
fn visit_str<E: serde::de::Error>(
self,
value: &str,
) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(source_kind_is_internal(value)))
}
fn visit_map<M: MapAccess<'de>>(
self,
mut map: M,
) -> std::result::Result<Self::Value, M::Error> {
let mut internal = false;
while let Some(key) = map.next_key::<String>()? {
internal |= key == "subagent" || key == "internal";
map.next_value::<IgnoredAny>()?;
}
Ok(InternalSource(internal))
}
fn visit_seq<A: SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(InternalSource(false))
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_bool<E: serde::de::Error>(
self,
_: bool,
) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> {
Ok(InternalSource(false))
}
}
deserializer.deserialize_any(SourceVisitor)
}
}
#[derive(Default, Deserialize)]
#[derive(Default)]
struct RolloutIdentityPayload {
id: Option<String>,
#[serde(default)]
#[cfg_attr(not(test), allow(dead_code))]
model_provider: Option<String>,
source: InternalSource,
thread_source: Option<String>,
}
@@ -1281,23 +1214,21 @@ impl RolloutIdentityPayload {
}
}
#[derive(Deserialize)]
struct RolloutIdentityRecord {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
payload: RolloutIdentityPayload,
}
/// Classify only the rollout's own first metadata record. A user-created fork
/// can replay an internal parent's metadata later without becoming internal.
pub(super) fn rollout_text_is_internal(text: &str) -> bool {
RolloutIdentityRecord::deserialize(&mut serde_json::Deserializer::from_str(text))
.is_ok_and(|record| record.kind == "session_meta" && record.payload.is_internal())
read_first_meta(text.as_bytes(), Path::new("inline rollout snapshot"))
.is_ok_and(|record| record.is_internal)
}
pub(super) fn rollout_path_has_syncable_identity(path: &Path) -> Result<bool> {
read_rollout_identity(path)
let file = fs::File::open(path).map_err(|error| io_err(path, error))?;
read_rollout_identity_from_reader(file, path)
.map(|record| !record.payload.is_internal())
.map_err(CodexxError::Config)
}
@@ -1338,18 +1269,25 @@ fn rollout_filename_thread_id(path: &Path) -> Option<&str> {
(is_filename_uuid(thread_id) && delimiter == "-").then_some(thread_id)
}
fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityRecord, String> {
let file = fs::File::open(path)
.map_err(|_| format!("无法读取会话文件来源信息: {}", path.display()))?;
let record = RolloutIdentityRecord::deserialize(&mut serde_json::Deserializer::from_reader(
BufReader::new(file),
))
.map_err(|_| {
fn read_rollout_identity_from_reader<R: Read>(
reader: R,
path: &Path,
) -> std::result::Result<RolloutIdentityRecord, String> {
let first = read_first_meta(reader, path).map_err(|error| {
format!(
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {}",
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {} ({error})",
path.display()
)
})?;
let record = RolloutIdentityRecord {
kind: "session_meta".into(),
payload: RolloutIdentityPayload {
id: Some(first.id),
model_provider: first.provider,
source: InternalSource(first.is_internal),
thread_source: None,
},
};
if record.kind != "session_meta" {
return Err(format!("会话文件缺少起始 session_meta: {}", path.display()));
}
@@ -1371,6 +1309,12 @@ fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityReco
Ok(record)
}
fn read_rollout_identity(path: &Path) -> std::result::Result<RolloutIdentityRecord, String> {
let file = fs::File::open(path)
.map_err(|_| format!("无法读取会话文件来源信息: {}", path.display()))?;
read_rollout_identity_from_reader(file, path)
}
fn is_authoritative_rollout(
codex_dir: &Path,
path: &Path,
@@ -2074,6 +2018,252 @@ mod tests {
assert!(scan.thread_ids.contains("fork"));
assert!(!scan.thread_ids.contains("internal"));
assert_eq!(scan.changes.len(), 1);
let original_hash: [u8; 32] = Sha256::digest(fork_text.as_bytes()).into();
assert_eq!(
scan.verified_rollout_hashes.get(&scan.changes[0].path),
Some(&original_hash)
);
fs::remove_dir_all(dir).unwrap();
}
const LEGACY_READ_LIMIT: u64 = 32 * 1024 * 1024;
#[test]
fn streaming_hash_uses_fixed_read_buffers_and_propagates_io_errors() {
struct CountedReader {
remaining: usize,
largest: usize,
}
impl Read for CountedReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.largest = self.largest.max(buffer.len());
let count = buffer.len().min(self.remaining);
buffer[..count].fill(b'x');
self.remaining -= count;
Ok(count)
}
}
let mut reader = CountedReader {
remaining: 97 * 1024 * 1024,
largest: 0,
};
hash_reader(&mut reader).unwrap();
assert_eq!(reader.remaining, 0);
assert_eq!(reader.largest, 64 * 1024);
struct FailingReader;
impl Read for FailingReader {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("synthetic read failure"))
}
}
assert!(hash_reader(FailingReader).is_err());
}
fn padded_rollout(path: &Path, id: &str, provider: &str, size: u64) {
let header = serde_json::json!({"type":"session_meta","payload":{"id":id,"model_provider":provider,"source":"vscode"}}).to_string() + "\n";
let mut file = fs::File::create(path).unwrap();
file.write_all(header.as_bytes()).unwrap();
let padding = [b' '; 64 * 1024];
let mut remaining = size - header.len() as u64;
while remaining > 0 {
let count = remaining.min(padding.len() as u64) as usize;
file.write_all(&padding[..count]).unwrap();
remaining -= count as u64;
}
}
#[test]
fn streaming_scan_accepts_below_exact_and_above_previous_limit() {
let dir = temp_codex_dir("streaming-rollout-boundaries");
let sessions = dir.join("sessions");
fs::create_dir_all(&sessions).unwrap();
for (name, size) in [
("below", LEGACY_READ_LIMIT - 1),
("exact", LEGACY_READ_LIMIT),
("above", LEGACY_READ_LIMIT + 1),
] {
padded_rollout(
&sessions.join(format!("rollout-{name}.jsonl")),
name,
"custom",
size,
);
}
let scan =
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
assert!(scan.blocked_failures.is_empty());
assert_eq!(scan.thread_ids.len(), 3);
assert_eq!(scan.verified_rollout_hashes.len(), 3);
assert_eq!(scan.session_meta_count, 3);
assert!(scan.changes.is_empty());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn invalid_large_user_files_fail_while_internal_and_excluded_files_are_ignored() {
let dir = temp_codex_dir("streaming-invalid-rollouts");
let sessions = dir.join("sessions");
fs::create_dir_all(&sessions).unwrap();
for (name, source) in [
("user", "vscode"),
("internal", "internal"),
("excluded", "vscode"),
] {
let path = sessions.join(format!("rollout-{name}.jsonl"));
let header = serde_json::json!({"type":"session_meta","payload":{"id":name,"model_provider":"openai","source":source}});
fs::write(&path, format!("{header}\n")).unwrap();
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_len(LEGACY_READ_LIMIT + 1)
.unwrap();
}
let scan = scan_provider_rollouts(
&dir,
"custom",
&HashSet::from(["excluded".into()]),
&HashMap::new(),
)
.unwrap();
assert_eq!(scan.scan_failures.len(), 1);
assert!(scan.blocked_failures.is_empty());
assert!(scan.changes.is_empty());
assert!(scan.verified_rollout_hashes.is_empty());
assert_eq!(scan.internal_thread_ids, HashSet::from(["internal".into()]));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn provider_expansion_above_previous_limit_is_staged_and_reversible() {
let dir = temp_codex_dir("streaming-output-expansion");
fs::create_dir_all(dir.join("sessions")).unwrap();
let path = dir.join("sessions/rollout-expanded.jsonl");
padded_rollout(&path, "expanded", "tiny", LEGACY_READ_LIMIT);
let original_hash = hash_rollout_file(&path).unwrap();
let original_mtime = fs::metadata(&path).unwrap().modified().unwrap();
let scan = scan_provider_rollouts(
&dir,
"provider-longer-than-tiny",
&HashSet::new(),
&HashMap::new(),
)
.unwrap();
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
assert_eq!(scan.changes.len(), 1);
let change = &scan.changes[0];
assert!(change.original_text.is_empty());
assert!(change.next_text.is_empty());
let staged = change.streamed.as_ref().unwrap();
assert!(fs::metadata(&staged.next_path).unwrap().len() > LEGACY_READ_LIMIT);
assert_eq!(hash_rollout_file(&path).unwrap(), original_hash);
let (applied, skipped) = apply_session_changes(&scan.changes).unwrap();
assert_eq!(applied.len(), 1);
assert!(skipped.is_empty());
assert_eq!(
read_rollout_identity(&path)
.unwrap()
.payload
.model_provider
.as_deref(),
Some("provider-longer-than-tiny")
);
assert_eq!(
fs::metadata(&path).unwrap().modified().unwrap(),
original_mtime
);
restore_session_changes(&applied).unwrap();
assert_eq!(hash_rollout_file(&path).unwrap(), original_hash);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn disk_snapshots_do_not_accumulate_conversation_text_in_memory() {
let dir = temp_codex_dir("streaming-snapshot-budget");
fs::create_dir_all(dir.join("sessions")).unwrap();
for index in 0..4 {
let id = format!("budget-{index}");
padded_rollout(
&dir.join(format!("sessions/rollout-{id}.jsonl")),
&id,
"openai",
22 * 1024 * 1024,
);
}
let scan =
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
assert!(scan.scan_failures.is_empty(), "{:?}", scan.scan_failures);
assert!(scan.blocked_failures.is_empty());
assert_eq!(scan.changes.len(), 4);
assert_eq!(scan.provider_candidate_paths.len(), 4);
assert_eq!(
scan.changes
.iter()
.map(|c| c.original_text.capacity() + c.next_text.capacity())
.sum::<usize>(),
0
);
assert!(scan.changes.iter().all(|c| c.streamed.is_some()));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn apply_and_restore_preserve_externally_grown_rollouts() {
let dir = temp_codex_dir("streaming-apply-restore");
let path = dir.join("rollout-grown.jsonl");
fs::write(&path, "external fixture").unwrap();
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_len(LEGACY_READ_LIMIT + 1)
.unwrap();
let change = SessionFileChange {
path: path.clone(),
original_text: "external fixture".into(),
next_text: "rewritten fixture".into(),
original_mtime: None,
streamed: None,
};
let (applied, skipped) = apply_session_changes(std::slice::from_ref(&change)).unwrap();
assert!(applied.is_empty());
assert_eq!(skipped, vec![path.clone()]);
assert!(restore_session_changes(std::slice::from_ref(&change)).is_err());
assert_eq!(fs::metadata(&path).unwrap().len(), LEGACY_READ_LIMIT + 1);
assert!(ensure_rollout_snapshot_unchanged(&path, &change.original_hash()).is_err());
assert!(rollout_path_has_syncable_identity(&path).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn corrupted_disk_snapshot_cannot_replace_log_and_prior_changes_roll_back() {
let dir = temp_codex_dir("streaming-staged-write-failure");
fs::create_dir_all(dir.join("sessions")).unwrap();
for id in ["a", "b"] {
padded_rollout(
&dir.join(format!("sessions/rollout-{id}.jsonl")),
id,
"openai",
1024,
);
}
let scan =
scan_provider_rollouts(&dir, "custom", &HashSet::new(), &HashMap::new()).unwrap();
let original_hashes: Vec<_> = scan
.changes
.iter()
.map(|c| (c.path.clone(), c.original_hash()))
.collect();
fs::write(
&scan.changes[1].streamed.as_ref().unwrap().next_path,
b"corrupted snapshot",
)
.unwrap();
assert!(apply_session_changes(&scan.changes).is_err());
for (path, hash) in original_hashes {
assert_eq!(hash_rollout_file(&path).unwrap(), hash);
}
fs::remove_dir_all(dir).unwrap();
}
+458 -45
View File
@@ -1,5 +1,6 @@
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
use super::directory_identity::session_directory_identities;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
@@ -127,10 +128,15 @@ fn scan_provider_sync_data(
sqlite.mismatched_thread_ids = mismatched_thread_ids;
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
// Only diagnostics from this authority scan are needed below. Drop its
// planned file copies before scanning the complete provider history.
let (indexed_rollout_failures, indexed_blocked_failures) = {
let rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
};
(rollouts.scan_failures, rollouts.blocked_failures)
};
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
let mut rollouts = scan_provider_rollouts(
@@ -169,29 +175,24 @@ fn scan_provider_sync_data(
let mut scan_failures = discovery.active_scan_failures.clone();
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
scan_failures.extend(sqlite.scan_failures.iter().cloned());
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
scan_failures.extend(indexed_rollout_failures.iter().cloned());
scan_failures.extend(indexed_blocked_failures);
// A size/buffer limit means the rollout was not completely inspected.
// This is blocking even for an orphan, rather than a success with a warning.
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
// A failed eligible rollout read/parse is an incomplete check, including
// unindexed files. It must not become an apparently successful sync.
scan_failures.extend(rollouts.scan_failures.iter().cloned());
for path in &discovery.unreadable_paths {
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
}
let indexed_failures = indexed_rollouts
.scan_failures
.iter()
.cloned()
.collect::<HashSet<_>>();
let mut warnings = rollouts.warnings.clone();
// The full provider/index scans below own warnings and blocking failures;
// repeating the lightweight identity scan's messages would duplicate them.
warnings.extend(active_sqlite.warnings.iter().cloned());
warnings.extend(sqlite.warnings.iter().cloned());
warnings.extend(legacy_index_warnings);
warnings.extend(
rollouts
.scan_failures
.iter()
.filter(|failure| !indexed_failures.contains(*failure))
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
);
let mut seen = HashSet::new();
scan_failures.retain(|failure| seen.insert(failure.clone()));
seen.clear();
@@ -245,8 +246,12 @@ pub(super) fn session_sync_status_with_discovery(
Err(error) => {
let mut failures = scan.scan_failures.clone();
failures.push(format!("无法读取当前活动会话列表: {error}"));
let (directory_identities, pin_scope_key) =
session_directory_identities(codex_dir, &[]);
return Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
directory_identities,
pin_scope_key: Some(pin_scope_key),
target_provider: target,
rollout_files: scan.rollouts.rollout_files,
session_meta_count: scan.rollouts.session_meta_count,
@@ -284,10 +289,14 @@ pub(super) fn session_sync_status_with_discovery(
&& mismatched_ids.contains(&session.id);
}
let needs_sync = !scan.rollouts.changes.is_empty()
|| !scan.rollouts.mismatched_thread_ids.is_empty()
|| scan.sqlite.mismatched_threads > 0
|| scan.catalog.total_updates() > 0;
let (directory_identities, pin_scope_key) = session_directory_identities(codex_dir, &sessions);
Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
directory_identities,
pin_scope_key: Some(pin_scope_key),
target_provider: target,
rollout_files: scan.rollouts.rollout_files,
session_meta_count: scan.rollouts.session_meta_count,
@@ -391,6 +400,7 @@ where
backup_dir: String::new(),
});
}
drop(preflight_scan);
hook(MutationPoint::BeforeSqliteLock)?;
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
@@ -411,6 +421,14 @@ where
{
rollback_open_transactions(&mut pending_sqlite);
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
if !status.scan_complete {
return Err(scan_failure_error(&status.scan_failures));
}
if status.needs_sync {
return Err(CodexxError::Config(
"会话状态在检查后发生变化,未完成同步;请重新检查。".to_string(),
));
}
return Ok(SessionSyncResult {
status,
updated_rollouts: 0,
@@ -458,25 +476,32 @@ where
}
};
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
.and_then(|status| {
if !status.scan_complete {
return Err(scan_failure_error(&status.scan_failures));
}
if status.needs_sync {
return Err(CodexxError::Config(
"会话日志与索引仍不一致,未完成同步;请重新检查。".to_string(),
));
}
Ok(status)
})
.map_err(|error| {
CodexxError::Config(format!(
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
))
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
mutation_error(
CodexxError::Config(format!("同步后复查未通过,本轮同步未完成:{error}")),
recovery_errors,
)
})?;
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
status.backup_dir = Some(backup.dir.display().to_string());
if prune_warning.is_some() {
status
.warnings
.push("同步已完成,但旧备份暂未清理。".to_string());
}
if !mutation.skipped_rollouts.is_empty() {
status.warnings.push(format!(
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
mutation.skipped_rollouts.len()
));
}
Ok(SessionSyncResult {
status,
updated_rollouts: mutation.applied_rollouts,
@@ -489,9 +514,12 @@ where
mod tests {
use super::*;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::io::{BufRead, Read, Seek, SeekFrom};
use std::sync::atomic::{AtomicU64, Ordering};
const SHARED_SESSION_PROVIDER: &str = "custom";
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -676,6 +704,383 @@ mod tests {
.expect("write rollout");
}
fn pad_rollout_to_size(path: &Path, size: u64) {
let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
let mut remaining = size.checked_sub(file.metadata().unwrap().len()).unwrap();
let padding = [b' '; 64 * 1024];
while remaining > 0 {
let count = remaining.min(padding.len() as u64) as usize;
file.write_all(&padding[..count]).unwrap();
remaining -= count as u64;
}
}
fn rollout_digest(path: &Path) -> Vec<u8> {
rollout_digest_from_offset(path, 0)
}
fn rollout_digest_from_offset(path: &Path, offset: u64) -> Vec<u8> {
let mut file = fs::File::open(path).unwrap();
file.seek(SeekFrom::Start(offset)).unwrap();
let mut digest = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer).unwrap();
if count == 0 {
return digest.finalize().to_vec();
}
digest.update(&buffer[..count]);
}
}
fn append_chat_record(path: &Path) {
fs::OpenOptions::new().append(true).open(path).unwrap()
.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"keep this conversation\"}}\n")
.unwrap();
}
fn read_first_rollout_record(path: &Path) -> String {
let mut first_record = String::new();
std::io::BufReader::new(fs::File::open(path).unwrap())
.read_line(&mut first_record)
.unwrap();
first_record
}
#[test]
fn streamed_rollouts_below_at_and_above_legacy_limit_sync_logs_databases_and_catalogs() {
for (name, size) in [
("normal", 512),
("below-old-limit", LEGACY_LIMIT - 1),
("exact-old-limit", LEGACY_LIMIT),
("above-old-limit", LEGACY_LIMIT + 1),
] {
let dir = temp_codex_dir(name);
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000701";
let rollout = write_rollout(&dir, id, "openai");
let old_record_len = read_first_rollout_record(&rollout).len() as u64;
append_chat_record(&rollout);
pad_rollout_to_size(&rollout, size);
let conversation_before = rollout_digest_from_offset(&rollout, old_record_len);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(status.scan_complete, "{name}: {:?}", status.scan_failures);
assert!(status.needs_sync, "{name}");
let result =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{name}"
);
assert_eq!(result.updated_rollouts, 1, "{name}");
assert_eq!(thread_provider(&database, id), "custom", "{name}");
assert_eq!(catalog_provider(&catalog, id), "custom", "{name}");
let first_record = read_first_rollout_record(&rollout);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollout).unwrap().len(), size, "{name}");
assert_eq!(
rollout_digest_from_offset(&rollout, first_record.len() as u64),
conversation_before,
"conversation bytes must be preserved: {name}"
);
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn streamed_ninety_seven_mib_single_chat_line_syncs_without_losing_conversation_bytes() {
let dir = temp_codex_dir("streamed-97-mib-chat-record");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000709";
let rollout = write_rollout(&dir, id, "openai");
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
let total_size = 97 * 1024 * 1024u64;
let prefix =
b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"";
let suffix = b"\"}}\n";
let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap();
file.write_all(prefix).unwrap();
let mut remaining =
total_size - first_record_len - prefix.len() as u64 - suffix.len() as u64;
let body_chunk = [b'x'; 64 * 1024];
while remaining > 0 {
let count = remaining.min(body_chunk.len() as u64) as usize;
file.write_all(&body_chunk[..count]).unwrap();
remaining -= count as u64;
}
file.write_all(suffix).unwrap();
file.sync_all().unwrap();
drop(file);
let conversation_before = rollout_digest_from_offset(&rollout, first_record_len);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
.expect("a large valid single JSONL record must synchronize successfully");
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, id), "custom");
assert_eq!(catalog_provider(&catalog, id), "custom");
let first_record = read_first_rollout_record(&rollout);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollout).unwrap().len(), total_size);
assert_eq!(
rollout_digest_from_offset(&rollout, first_record.len() as u64),
conversation_before,
"the large chat record must retain every original byte"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn streamed_multiple_rollouts_exceeding_legacy_snapshot_budget_sync_together() {
let dir = temp_codex_dir("streamed-multi-large-rollout-budget");
write_config(&dir, "custom");
let ids = [
"019f6000-0000-7000-8000-000000000711",
"019f6000-0000-7000-8000-000000000712",
"019f6000-0000-7000-8000-000000000713",
];
let file_size = 45 * 1024 * 1024u64;
assert!(file_size * ids.len() as u64 > 128 * 1024 * 1024);
let mut rollouts = Vec::new();
let mut conversation_digests = Vec::new();
for id in ids {
let rollout = write_rollout(&dir, id, "openai");
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
append_chat_record(&rollout);
pad_rollout_to_size(&rollout, file_size);
conversation_digests.push(rollout_digest_from_offset(&rollout, first_record_len));
rollouts.push(rollout);
}
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, ids[0], "openai", &rollouts[0]);
let conn = Connection::open(&database).unwrap();
for index in 1..ids.len() {
conn.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'large history', ?2)",
(ids[index], rollouts[index].display().to_string()),
).unwrap();
}
drop(conn);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &ids.map(|id| (id, "openai")));
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
.expect("the combined size of valid rollouts must not block synchronization");
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, ids.len());
for index in 0..ids.len() {
assert_eq!(thread_provider(&database, ids[index]), "custom");
assert_eq!(catalog_provider(&catalog, ids[index]), "custom");
let first_record = read_first_rollout_record(&rollouts[index]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollouts[index]).unwrap().len(), file_size);
assert_eq!(
rollout_digest_from_offset(&rollouts[index], first_record.len() as u64),
conversation_digests[index],
"conversation bytes must remain unchanged for {}",
ids[index]
);
}
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn large_invalid_jsonl_rollout_reports_parse_failure_and_keeps_indexes_unchanged() {
for (name, file_provider, database_provider) in [
("file-and-index-old", "openai", "openai"),
("index-already-target", "openai", "custom"),
("first-record-matches", "custom", "custom"),
] {
let dir = temp_codex_dir(name);
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000702";
let rollout = write_rollout(&dir, id, file_provider);
fs::OpenOptions::new()
.write(true)
.open(&rollout)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
let before = rollout_digest(&rollout);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, database_provider, &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, database_provider)]);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete, "{name}");
assert!(
status
.scan_failures
.iter()
.any(|failure| failure.contains("JSONL")),
"{name}: {:?}",
status.scan_failures
);
let error =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert!(error.to_string().contains("JSONL"), "{name}: {error}");
assert_eq!(thread_provider(&database, id), database_provider, "{name}");
assert_eq!(catalog_provider(&catalog, id), database_provider, "{name}");
assert_eq!(rollout_digest(&rollout), before, "{name}");
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn large_invalid_orphan_blocks_sync_without_changing_normal_logs_or_indexes() {
let dir = temp_codex_dir("large-invalid-orphan-blocked");
write_config(&dir, "custom");
let ordinary_id = "019f6000-0000-7000-8000-000000000703";
let ordinary = write_rollout(&dir, ordinary_id, "openai");
let before = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, ordinary_id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(ordinary_id, "openai")]);
let orphan_id = "019f6000-0000-7000-8000-000000000704";
let invalid_orphan = write_rollout(&dir, orphan_id, "openai");
fs::OpenOptions::new()
.write(true)
.open(&invalid_orphan)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
let orphan_before = rollout_digest(&invalid_orphan);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete && status.needs_sync);
let error =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert!(error.to_string().contains("JSONL"), "{error}");
assert_eq!(fs::read(&ordinary).unwrap(), before);
assert_eq!(rollout_digest(&invalid_orphan), orphan_before);
assert_eq!(thread_provider(&database, ordinary_id), "openai");
assert_eq!(catalog_provider(&catalog, ordinary_id), "openai");
assert!(catalog_provider_and_visibility(&catalog, orphan_id).is_none());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_orphan_read_or_parse_cannot_be_reported_as_successful_sync() {
for suffix in [b"\xff".as_slice(), b"not-json\n".as_slice()] {
let dir = temp_codex_dir("failed-orphan-check-blocks-sync");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000705";
let ordinary = write_rollout(&dir, id, "openai");
let original = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let orphan = write_rollout(&dir, "019f6000-0000-7000-8000-000000000706", "openai");
fs::OpenOptions::new()
.append(true)
.open(&orphan)
.unwrap()
.write_all(suffix)
.unwrap();
let orphan_original = fs::read(&orphan).unwrap();
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete && !status.scan_failures.is_empty());
assert!(sync_sessions_provider_inner(Some(dir.display().to_string()), None).is_err());
assert_eq!(thread_provider(&database, id), "openai");
assert_eq!(catalog_provider(&catalog, id), "openai");
assert_eq!(fs::read(&ordinary).unwrap(), original);
assert_eq!(fs::read(&orphan).unwrap(), orphan_original);
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn failed_final_recheck_rolls_back_committed_logs_and_indexes() {
for scenario in ["invalid", "large-invalid", "new-mismatch"] {
let dir = temp_codex_dir("failed-final-recheck");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000707";
let ordinary = write_rollout(&dir, id, "openai");
let original = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let late = dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000708.jsonl");
let mut late_digest = None;
let error =
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if matches!(point, MutationPoint::AfterSqliteCommit(_)) && !late.exists() {
if scenario == "invalid" {
fs::write(&late, b"\xff").unwrap();
} else {
write_rollout_at(
&late,
"019f6000-0000-7000-8000-000000000708",
"openai",
);
if scenario == "large-invalid" {
fs::OpenOptions::new()
.write(true)
.open(&late)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
}
}
late_digest = Some(rollout_digest(&late));
}
Ok(())
})
.expect_err("a failed final check must not return a successful sync");
assert!(
error.to_string().contains("同步后复查未通过"),
"{scenario}: {error}"
);
assert_eq!(thread_provider(&database, id), "openai", "{scenario}");
assert_eq!(catalog_provider(&catalog, id), "openai", "{scenario}");
assert_eq!(fs::read(&ordinary).unwrap(), original, "{scenario}");
assert_eq!(
rollout_digest(&late),
late_digest.unwrap(),
"preserve external file: {scenario}"
);
fs::remove_dir_all(dir).unwrap();
}
}
fn write_subagent_rollout(
codex_dir: &Path,
id: &str,
@@ -975,7 +1380,7 @@ mod tests {
}
#[test]
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
fn malformed_standard_orphan_keeps_check_incomplete_without_mutation() {
let codex_dir = temp_codex_dir("malformed-standard-orphan");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000502";
@@ -985,26 +1390,30 @@ mod tests {
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
b"\xff",
)
.expect("write invalid orphan rollout");
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
let active_before = fs::read(&active).unwrap();
let database = codex_dir.join("state_5.sqlite");
let database_before = fs::read(&database).unwrap();
let orphan = codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl"));
fs::write(&orphan, b"\xff").expect("write invalid orphan rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan active sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.scan_complete);
assert!(!status.scan_failures.is_empty());
assert_eq!(status.rollout_files, 2);
assert_eq!(status.session_meta_count, 1);
assert!(!status.needs_sync);
assert_eq!(status.warnings.len(), 1);
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
assert_eq!(fs::read(&active).unwrap(), active_before);
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
fn malformed_unreferenced_rollout_keeps_check_incomplete_without_mutation() {
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000504";
@@ -1013,19 +1422,23 @@ mod tests {
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
b"\xff",
)
.expect("write invalid unreferenced rollout");
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
let active_before = fs::read(&active).unwrap();
let database = codex_dir.join("state_5.sqlite");
let database_before = fs::read(&database).unwrap();
let orphan = codex_dir.join("sessions/rollout-imported-orphan.jsonl");
fs::write(&orphan, b"\xff").expect("write invalid unreferenced rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan referenced sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.scan_complete);
assert!(!status.scan_failures.is_empty());
assert_eq!(status.rollout_files, 2);
assert_eq!(status.session_meta_count, 1);
assert_eq!(status.warnings.len(), 1);
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
assert_eq!(fs::read(&active).unwrap(), active_before);
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
@@ -3,8 +3,8 @@ use super::catalog::{
restore_catalog_updates, CatalogRepairThread,
};
use super::storage::{
apply_session_changes, restore_session_changes, rollout_path_has_syncable_identity,
rollout_text_is_internal, sqlite_subagent_thread_ids,
apply_session_changes_with_hook, hash_rollout_file, restore_session_changes,
rollout_path_has_syncable_identity, rollout_text_is_internal, sqlite_subagent_thread_ids,
};
use super::types::{RolloutScan, SessionFileChange};
use crate::error::{CodexxError, Result};
@@ -37,6 +37,7 @@ impl SqliteUpdateCounts {
#[derive(Debug, Default)]
pub(super) struct MutationJournal {
applied_rollouts: Vec<SessionFileChange>,
expected_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
}
@@ -285,6 +286,7 @@ where
{
let mut updated = SqliteUpdateCounts::default();
for index in 0..pending.len() {
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
let before_commit = sqlite_data_version(&pending[index].observer)?;
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
path: pending[index].path.clone(),
@@ -311,6 +313,9 @@ where
return Err(error);
}
}
// The final commit hook can publish an external rollout update as well.
// Returning success requires the complete candidate set to remain verified.
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
Ok(updated)
}
@@ -418,13 +423,13 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
pub(super) enum MutationPoint {
BeforeSqliteLock,
BeforeRolloutMutation,
BeforeEachRolloutMutation(usize),
AfterRolloutMutation,
AfterSqliteCommit(usize),
}
pub(super) struct MutationResult {
pub(super) applied_rollouts: usize,
pub(super) skipped_rollouts: Vec<PathBuf>,
pub(super) sqlite_updates: SqliteUpdateCounts,
}
@@ -434,7 +439,12 @@ pub(super) struct MutationResult {
fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
let mut paths = rollouts.provider_candidate_paths.clone();
for change in &rollouts.changes {
if rollout_text_is_internal(&change.original_text) {
let original_is_internal = if let Some(snapshot) = &change.streamed {
!rollout_path_has_syncable_identity(&snapshot.original_path)?
} else {
rollout_text_is_internal(&change.original_text)
};
if original_is_internal {
return Err(CodexxError::Config(
"内部会话不能同步为普通会话。".to_string(),
));
@@ -452,6 +462,46 @@ fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
Ok(())
}
fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u8; 32]>> {
let mut expected = HashMap::new();
for path in &rollouts.provider_candidate_paths {
let hash = rollouts.verified_rollout_hashes.get(path).ok_or_else(|| {
CodexxError::Config(format!(
"缺少会话文件验证快照,已停止同步:{}",
path.display()
))
})?;
expected.insert(path.clone(), *hash);
}
for change in &rollouts.changes {
let original_hash = change.original_hash();
if expected
.get(&change.path)
.is_some_and(|hash| *hash != original_hash)
{
return Err(CodexxError::Config(format!(
"会话文件改写快照与扫描结果不一致,已停止同步:{}",
change.path.display()
)));
}
// A planned change already carries the scan's verified original snapshot.
expected.insert(change.path.clone(), original_hash);
}
Ok(expected)
}
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
for (path, expected_hash) in expected {
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
return Err(CodexxError::Config(format!(
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
path.display()
)));
}
}
Ok(())
}
pub(super) fn execute_provider_sync_mutation<F>(
rollouts: &RolloutScan,
pending_sqlite: &mut [PendingSqliteUpdate],
@@ -465,12 +515,35 @@ where
F: FnMut(MutationPoint) -> Result<()>,
{
let result = (|| -> Result<MutationResult> {
if !rollouts.blocked_failures.is_empty() {
return Err(CodexxError::Config(format!(
"会话扫描未完成,已停止同步:{}",
rollouts.blocked_failures.join(";")
)));
}
journal.expected_rollout_hashes = expected_rollout_hashes(rollouts)?;
hook(MutationPoint::BeforeRolloutMutation)?;
validate_rollout_classification(rollouts)?;
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
let (applied_rollouts, skipped_rollouts) =
apply_session_changes_with_hook(&rollouts.changes, |index| {
hook(MutationPoint::BeforeEachRolloutMutation(index))
})?;
journal.applied_rollouts = applied_rollouts;
for change in &journal.applied_rollouts {
journal
.expected_rollout_hashes
.insert(change.path.clone(), change.next_hash());
}
if !skipped_rollouts.is_empty() {
return Err(CodexxError::Config(format!(
"有 {} 个会话文件被占用或已发生变化,已停止同步;请退出 Codex 后重新检查会话。",
skipped_rollouts.len()
)));
}
hook(MutationPoint::AfterRolloutMutation)?;
validate_rollout_classification(rollouts)?;
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
apply_sqlite_updates(
pending_sqlite,
rollouts,
@@ -481,7 +554,6 @@ where
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
Ok(MutationResult {
applied_rollouts: journal.applied_rollouts.len(),
skipped_rollouts,
sqlite_updates,
})
})();
@@ -2,13 +2,16 @@ use super::*;
use crate::sessions::backup::provider_sync_backup_root;
use crate::sessions::sync::sync_sessions_provider_with_hook;
use rusqlite::{Connection, OpenFlags};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
const LARGE_ROLLOUT_TEST_BYTES: u64 = 33 * 1024 * 1024;
fn temp_dir(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
@@ -73,6 +76,633 @@ fn sqlite_quick_check(path: &Path) -> String {
.expect("run quick check")
}
struct RolloutFailureFixture {
codex_dir: PathBuf,
ids: [&'static str; 2],
rollouts: [PathBuf; 2],
originals: [Vec<u8>; 2],
database: PathBuf,
catalog: PathBuf,
}
impl RolloutFailureFixture {
fn new(name: &str) -> Self {
let codex_dir = temp_dir(name);
let ids = [
"019f6000-0000-7000-8000-000000000991",
"019f6000-0000-7000-8000-000000000992",
];
let rollouts = ids.map(|id| codex_dir.join(format!("sessions/rollout-test-{id}.jsonl")));
let originals = std::array::from_fn(|index| write_rollout(&rollouts[index], ids[index]));
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, ids[0], &rollouts[0]);
Connection::open(&database)
.unwrap()
.execute(
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
(ids[1], rollouts[1].display().to_string()),
)
.unwrap();
let catalog = codex_dir.join("sqlite/codex-dev.db");
fs::create_dir_all(catalog.parent().unwrap()).unwrap();
let conn = Connection::open(&catalog).unwrap();
conn.execute_batch(
"CREATE TABLE local_thread_catalog (
host_id TEXT NOT NULL, thread_id TEXT NOT NULL, model_provider TEXT NOT NULL,
PRIMARY KEY (host_id, thread_id));
CREATE TABLE local_thread_catalog_metadata (
id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL);
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);",
)
.unwrap();
for id in ids {
conn.execute(
"INSERT INTO local_thread_catalog VALUES ('local', ?1, 'openai')",
[id],
)
.unwrap();
}
Self {
codex_dir,
ids,
rollouts,
originals,
database,
catalog,
}
}
fn scan(&self) -> RolloutScan {
let changes = (0..2)
.map(|index| {
let original_text = String::from_utf8(self.originals[index].clone()).unwrap();
SessionFileChange {
path: self.rollouts[index].clone(),
next_text: original_text.replace("\"openai\"", "\"custom\""),
original_text,
original_mtime: None,
streamed: None,
}
})
.collect();
RolloutScan {
changes,
provider_candidate_paths: self.rollouts.iter().cloned().collect(),
verified_rollout_hashes: (0..2)
.map(|index| {
(
self.rollouts[index].clone(),
Sha256::digest(&self.originals[index]).into(),
)
})
.collect(),
..RolloutScan::default()
}
}
fn set_rollouts_to_target_provider(&mut self) {
for index in 0..2 {
self.originals[index] = String::from_utf8(self.originals[index].clone())
.unwrap()
.replace("\"openai\"", "\"custom\"")
.into_bytes();
fs::write(&self.rollouts[index], &self.originals[index]).unwrap();
}
}
fn catalog_provider(&self, id: &str) -> String {
Connection::open(&self.catalog)
.unwrap()
.query_row(
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
[id],
|row| row.get(0),
)
.unwrap()
}
fn assert_databases_unchanged(&self) {
for id in self.ids {
assert_eq!(thread_provider(&self.database, id), "openai");
assert_eq!(self.catalog_provider(id), "openai");
}
let (rows, revision): (i64, i64) = Connection::open(&self.catalog)
.unwrap()
.query_row(
"SELECT (SELECT COUNT(*) FROM local_thread_catalog),
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1)",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!((rows, revision), (2, 7));
}
fn sync_with_hook<F>(&self, hook: F) -> Result<super::super::types::SessionSyncResult>
where
F: FnMut(MutationPoint) -> Result<()>,
{
sync_sessions_provider_with_hook(Some(self.codex_dir.display().to_string()), None, hook)
}
}
impl Drop for RolloutFailureFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.codex_dir);
}
}
#[test]
fn blocked_rollout_scan_aborts_before_hooks_or_database_updates() {
let fixture = RolloutFailureFixture::new("bounded-blocked-scan");
let mut rollouts = fixture.scan();
rollouts
.blocked_failures
.push("会话文件读取失败,未完成扫描".to_string());
let mut pending =
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
let mut journal = MutationJournal::default();
let mut hook_called = false;
let error = execute_provider_sync_mutation(
&rollouts,
&mut pending,
"custom",
&HashMap::new(),
&fixture.ids.map(str::to_string).into_iter().collect(),
&mut journal,
&mut |_| {
hook_called = true;
Ok(())
},
)
.err()
.expect("blocked scan must fail");
assert!(error.to_string().contains("读取失败"));
assert!(!hook_called);
assert!(journal.applied_rollouts.is_empty());
assert!(rollback_mutation(&journal, &mut pending).is_empty());
fixture.assert_databases_unchanged();
for index in 0..2 {
assert_eq!(
fs::read(&fixture.rollouts[index]).unwrap(),
fixture.originals[index]
);
}
}
#[test]
fn concurrent_rollout_change_is_rejected_before_file_or_database_updates() {
let fixture = RolloutFailureFixture::new("bounded-skip-rollback");
let mut external = fixture.originals[1].clone();
external.extend_from_slice(
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-new-data\"}}\n",
);
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::BeforeRolloutMutation {
fs::write(&fixture.rollouts[1], &external).unwrap();
}
Ok(())
})
.expect_err("any skipped rollout must abort the full sync");
assert!(error.to_string().contains("已停止同步"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
}
#[test]
fn skipped_rollout_after_first_write_rolls_back_and_never_updates_indexes() {
let fixture = RolloutFailureFixture::new("streamed-skip-after-write");
let mut external = fixture.originals[1].clone();
external.extend_from_slice(
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-external-data\"}}\n",
);
let mut first_write_observed = false;
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::BeforeEachRolloutMutation(1) {
first_write_observed = fs::read_to_string(&fixture.rollouts[0])
.unwrap()
.contains("custom");
fs::write(&fixture.rollouts[1], &external).unwrap();
}
Ok(())
})
.expect_err("a skipped second rollout must fail and roll back the first write");
assert!(first_write_observed);
assert!(error.to_string().contains("已停止同步"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
}
#[test]
fn rollout_read_failure_after_scan_aborts_without_database_updates() {
let fixture = RolloutFailureFixture::new("bounded-read-failure");
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::BeforeRolloutMutation {
fs::remove_file(&fixture.rollouts[1]).unwrap();
}
Ok(())
})
.expect_err("unreadable full rollout must fail the sync");
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert!(
!fixture.rollouts[1].exists(),
"external deletion must be preserved"
);
}
#[cfg(unix)]
#[test]
fn rollout_atomic_write_failure_restores_earlier_files_without_database_updates() {
use std::os::unix::fs::PermissionsExt;
let mut fixture = RolloutFailureFixture::new("streaming-write-failure");
let blocked_parent = fixture.codex_dir.join("sessions/zz-write-blocked");
fs::create_dir_all(&blocked_parent).unwrap();
let second_path = blocked_parent.join(fixture.rollouts[1].file_name().unwrap());
fs::rename(&fixture.rollouts[1], &second_path).unwrap();
fixture.rollouts[1] = second_path;
Connection::open(&fixture.database)
.unwrap()
.execute(
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
(fixture.rollouts[1].display().to_string(), fixture.ids[1]),
)
.unwrap();
let mut reached_file_mutation = false;
let result = fixture.sync_with_hook(|point| {
if point == MutationPoint::BeforeEachRolloutMutation(1) {
reached_file_mutation = true;
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o500)).unwrap();
}
Ok(())
});
// Always restore the temporary fixture permissions before assertions/drop.
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o700)).unwrap();
let error = result.expect_err("temporary rollout write permission failure must fail sync");
assert!(
reached_file_mutation,
"failure must occur after scan and backup"
);
assert!(error.to_string().contains("IO error"), "{error}");
fixture.assert_databases_unchanged();
for index in 0..2 {
assert_eq!(
fs::read(&fixture.rollouts[index]).unwrap(),
fixture.originals[index]
);
}
}
#[test]
fn ordinary_provider_rewrite_after_mutation_aborts_without_database_or_catalog_changes() {
let fixture = RolloutFailureFixture::new("bounded-provider-race");
let external = fixture.originals[1].clone();
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::AfterRolloutMutation {
assert!(fs::read_to_string(&fixture.rollouts[0])
.unwrap()
.contains("\"custom\""));
assert!(fs::read_to_string(&fixture.rollouts[1])
.unwrap()
.contains("\"custom\""));
fs::write(&fixture.rollouts[1], &external).unwrap();
}
Ok(())
})
.expect_err("ordinary provider change after writes must fail sync");
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
}
#[test]
fn rollout_snapshot_is_rechecked_before_each_database_commit() {
let fixture = RolloutFailureFixture::new("bounded-between-commits");
let mut first_committed = false;
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::AfterSqliteCommit(0) {
first_committed = true;
assert!(
thread_provider(&fixture.database, fixture.ids[0]) == "custom"
|| fixture.catalog_provider(fixture.ids[0]) == "custom"
);
fs::write(&fixture.rollouts[1], &fixture.originals[1]).unwrap();
}
Ok(())
})
.expect_err("new rollout data before the second commit must abort and restore the first");
assert!(first_committed);
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
for index in 0..2 {
assert_eq!(
fs::read(&fixture.rollouts[index]).unwrap(),
fixture.originals[index]
);
}
}
#[test]
fn rollout_growth_after_scan_is_not_overwritten_or_reported_as_success() {
let fixture = RolloutFailureFixture::new("bounded-growth-before-mutation");
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::BeforeRolloutMutation {
fs::OpenOptions::new()
.write(true)
.open(&fixture.rollouts[1])
.unwrap()
.set_len(enlarged_len)
.unwrap();
}
Ok(())
})
.expect_err("concurrent growth must invalidate the verified snapshot");
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
enlarged_len
);
}
#[test]
fn rollout_growth_after_file_writes_preserves_external_data_and_rolls_back_other_files() {
let fixture = RolloutFailureFixture::new("bounded-growth-after-mutation");
let marker = b"keep-external-growth";
let marker_offset = LARGE_ROLLOUT_TEST_BYTES;
let enlarged_len = marker_offset + marker.len() as u64;
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::AfterRolloutMutation {
let mut external = fs::OpenOptions::new()
.write(true)
.open(&fixture.rollouts[1])
.unwrap();
external.set_len(enlarged_len).unwrap();
external.seek(SeekFrom::Start(marker_offset)).unwrap();
external.write_all(marker).unwrap();
}
Ok(())
})
.expect_err("growth after writes must prevent all database commits");
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
enlarged_len
);
let mut preserved = fs::File::open(&fixture.rollouts[1]).unwrap();
preserved.seek(SeekFrom::Start(marker_offset)).unwrap();
let mut restored_marker = vec![0; marker.len()];
preserved.read_exact(&mut restored_marker).unwrap();
assert_eq!(restored_marker, marker);
}
#[test]
fn rollout_read_failure_after_writes_restores_other_files_and_preserves_external_content() {
let fixture = RolloutFailureFixture::new("bounded-read-failure-after-writes");
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::AfterRolloutMutation {
fs::remove_file(&fixture.rollouts[1]).unwrap();
}
Ok(())
})
.expect_err("full snapshot read failure after writes must abort the sync");
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert!(
!fixture.rollouts[1].exists(),
"external deletion must be preserved"
);
}
#[test]
fn unchanged_candidate_without_verified_hash_is_rejected_before_mutation() {
let fixture = RolloutFailureFixture::new("bounded-unverified-candidate");
let mut rollouts = fixture.scan();
rollouts.changes.clear();
rollouts.verified_rollout_hashes.clear();
let mut pending =
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
let mut journal = MutationJournal::default();
let mut hook_called = false;
let error = execute_provider_sync_mutation(
&rollouts,
&mut pending,
"custom",
&HashMap::new(),
&fixture.ids.map(str::to_string).into_iter().collect(),
&mut journal,
&mut |_| {
hook_called = true;
Ok(())
},
)
.err()
.expect("unverified candidate must not take a fresh mutable file as its baseline");
assert!(
error.to_string().contains("缺少会话文件验证快照"),
"{error}"
);
assert!(!hook_called);
fixture.assert_databases_unchanged();
}
#[test]
fn unchanged_candidates_normally_repair_databases_without_rewriting_rollouts() {
let mut fixture = RolloutFailureFixture::new("bounded-matched-normal-repair");
fixture.set_rollouts_to_target_provider();
let result = fixture
.sync_with_hook(|_| Ok(()))
.expect("matched verified logs permit database repair");
assert_eq!(result.updated_rollouts, 0);
assert!(result.updated_threads > 0);
for index in 0..2 {
assert_eq!(
thread_provider(&fixture.database, fixture.ids[index]),
"custom"
);
assert_eq!(fixture.catalog_provider(fixture.ids[index]), "custom");
assert_eq!(
fs::read(&fixture.rollouts[index]).unwrap(),
fixture.originals[index]
);
}
}
#[test]
fn large_unchanged_candidate_repairs_databases_without_rewriting_the_file() {
let mut fixture = RolloutFailureFixture::new("streamed-large-matched-repair");
fixture.set_rollouts_to_target_provider();
let event = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"unchanged large history\"}}\n";
let chunk = event.repeat(512);
let mut file = fs::OpenOptions::new()
.append(true)
.open(&fixture.rollouts[1])
.unwrap();
let mut original_len = file.metadata().unwrap().len();
while original_len <= LARGE_ROLLOUT_TEST_BYTES {
file.write_all(&chunk).unwrap();
original_len += chunk.len() as u64;
}
file.sync_all().unwrap();
drop(file);
let original_hash = hash_rollout_file(&fixture.rollouts[1]).unwrap();
let result = fixture
.sync_with_hook(|_| Ok(()))
.expect("large matched files permit database repair");
assert_eq!(result.updated_rollouts, 0);
assert!(result.updated_threads > 0);
for id in fixture.ids {
assert_eq!(thread_provider(&fixture.database, id), "custom");
assert_eq!(fixture.catalog_provider(id), "custom");
}
assert_eq!(
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
original_len
);
assert_eq!(
hash_rollout_file(&fixture.rollouts[1]).unwrap(),
original_hash
);
}
#[test]
fn unchanged_candidate_provider_changes_before_or_after_mutation_block_database_repair() {
for stage in [
MutationPoint::BeforeRolloutMutation,
MutationPoint::AfterRolloutMutation,
] {
let mut fixture = RolloutFailureFixture::new("bounded-matched-provider-race");
fixture.set_rollouts_to_target_provider();
let external = String::from_utf8(fixture.originals[1].clone())
.unwrap()
.replace("\"custom\"", "\"openai\"")
.into_bytes();
let error = fixture
.sync_with_hook(|point| {
if point == stage {
fs::write(&fixture.rollouts[1], &external).unwrap();
}
Ok(())
})
.expect_err("changed already-matched candidate must not permit database repair");
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
}
}
#[test]
fn unchanged_candidate_growth_before_or_after_mutation_blocks_database_repair() {
for stage in [
MutationPoint::BeforeRolloutMutation,
MutationPoint::AfterRolloutMutation,
] {
let mut fixture = RolloutFailureFixture::new("bounded-matched-growth-race");
fixture.set_rollouts_to_target_provider();
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
let error = fixture
.sync_with_hook(|point| {
if point == stage {
fs::OpenOptions::new()
.write(true)
.open(&fixture.rollouts[1])
.unwrap()
.set_len(enlarged_len)
.unwrap();
}
Ok(())
})
.expect_err("changed already-matched candidate must prevent database repair");
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
enlarged_len
);
let mut external = fs::File::open(&fixture.rollouts[1]).unwrap();
let mut prefix = vec![0; fixture.originals[1].len()];
external.read_exact(&mut prefix).unwrap();
assert_eq!(prefix, fixture.originals[1]);
}
}
#[test]
fn unchanged_candidate_change_after_final_commit_rolls_back_databases_and_preserves_external_log() {
let mut fixture = RolloutFailureFixture::new("bounded-matched-final-commit-race");
fixture.set_rollouts_to_target_provider();
let external = String::from_utf8(fixture.originals[1].clone())
.unwrap()
.replace("\"custom\"", "\"openai\"")
.into_bytes();
let mut last_commit_observed = false;
let error = fixture
.sync_with_hook(|point| {
if point == MutationPoint::AfterSqliteCommit(1) {
last_commit_observed = true;
assert_eq!(thread_provider(&fixture.database, fixture.ids[0]), "custom");
assert_eq!(fixture.catalog_provider(fixture.ids[0]), "custom");
fs::write(&fixture.rollouts[1], &external).unwrap();
}
Ok(())
})
.expect_err("the final commit hook must not bypass the complete snapshot validation");
assert!(last_commit_observed);
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
fixture.assert_databases_unchanged();
assert_eq!(
fs::read(&fixture.rollouts[0]).unwrap(),
fixture.originals[0]
);
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
}
#[test]
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
let codex_dir = temp_dir("multi-sqlite-update-failure");
@@ -1,6 +1,8 @@
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize)]
@@ -23,6 +25,10 @@ pub(crate) struct SessionPreview {
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionSyncStatus {
pub(crate) codex_dir: String,
#[serde(default)]
pub(crate) directory_identities: HashMap<String, String>,
#[serde(default)]
pub(crate) pin_scope_key: Option<String>,
pub(crate) target_provider: String,
pub(crate) rollout_files: usize,
pub(crate) session_meta_count: usize,
@@ -60,6 +66,9 @@ pub(crate) struct RolloutScan {
pub(crate) mismatched_session_meta: usize,
pub(crate) changes: Vec<SessionFileChange>,
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
/// SHA-256 of every completely validated candidate's original JSONL. Keep
/// matched rollouts covered without retaining another copy of their text.
pub(crate) verified_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
pub(crate) cwd_by_thread_id: HashMap<String, String>,
pub(crate) thread_ids: HashSet<String>,
/// Non-user threads identified from their own rollout metadata. Retained
@@ -68,6 +77,9 @@ pub(crate) struct RolloutScan {
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
/// Incomplete user-rollout scans that must block Provider/index mutations,
/// including unindexed files that would otherwise become only warnings.
pub(crate) blocked_failures: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -76,6 +88,24 @@ pub(crate) struct SessionFileChange {
pub(crate) original_text: String,
pub(crate) next_text: String,
pub(crate) original_mtime: Option<SystemTime>,
// Production snapshots live on disk; inline text is retained for small fixtures.
pub(super) streamed: Option<Arc<super::rollout_stream::StreamedSnapshot>>,
}
impl SessionFileChange {
pub(super) fn original_hash(&self) -> [u8; 32] {
self.streamed.as_ref().map_or_else(
|| Sha256::digest(self.original_text.as_bytes()).into(),
|snapshot| snapshot.original_hash,
)
}
pub(super) fn next_hash(&self) -> [u8; 32] {
self.streamed.as_ref().map_or_else(
|| Sha256::digest(self.next_text.as_bytes()).into(),
|snapshot| snapshot.next_hash,
)
}
}
#[derive(Debug, Default)]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.22",
"version": "0.3.23",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+366 -78
View File
@@ -1,19 +1,70 @@
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Download,
Folder,
FolderTree,
History,
Info,
Loader2,
Pin,
RefreshCw,
Search,
Trash2,
Zap,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
import {
mergeSessionDirectoryGroups,
pinnedSessionDirectoryKeys,
readSessionPinPreferences,
sessionPinStorageKey,
sessionsForDirectoryGroup,
toggleFolderPins,
type SessionDirectoryIdentities,
type SessionPinPreferences,
} from "../sessionDirectoryIdentity";
import "../styles/session-management.css";
const SESSION_ROW_HEIGHT = 58;
const SESSION_COLUMN_HEADER_HEIGHT = 34;
const SESSION_VIRTUAL_OVERSCAN_ROWS = 8;
const DEFAULT_SESSION_VIEWPORT_HEIGHT = 640;
const SESSION_ALL_FOLDERS_KEY = "__codexx_all_folders__";
// Defer the browser storage getter so unavailable storage is caught by the reader.
const sessionPinStorage = { getItem: (key: string) => localStorage.getItem(key) };
type ScopedSessionPinPreferences = SessionPinPreferences & {
storageKey: string;
};
function folderDisplayName(value: string, missing: string) {
if (!value) return missing;
const parts = value.replace(/\\/g, "/").split("/").filter(Boolean);
return parts[parts.length - 1] || missing;
}
type SessionVirtualRow = {
kind: "session";
key: string;
top: number;
height: number;
item: SessionPreview;
};
function findVirtualRowIndex(rows: SessionVirtualRow[], offset: number) {
let low = 0;
let high = rows.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (rows[middle].top + rows[middle].height <= offset) low = middle + 1;
else high = middle;
}
return low;
}
export type Lang = "zh" | "en";
export type SessionPreview = {
@@ -32,6 +83,8 @@ export type SessionPreview = {
export type SessionSyncStatus = {
codexDir: string;
directoryIdentities?: SessionDirectoryIdentities;
pinScopeKey?: string | null;
targetProvider: string;
rolloutFiles: number;
sessionMetaCount: number;
@@ -157,6 +210,7 @@ export function SessionManagementPage({
onDeleteSafetyConfirmedChange,
}: SessionManagementPageProps) {
const isChinese = lang === "zh";
const codexDir = sessionStatus?.codexDir || "default";
const copy = isChinese
? {
syncEyebrow: "会话同步",
@@ -170,14 +224,14 @@ export function SessionManagementPage({
clickToCheck: "点击检查会话",
scanIncomplete: "无法确认同步状态,请查看下方原因",
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
allSynced: "普通会话已同步",
allSynced: "全部会话已同步",
sessionCount: (count: number) => `${count} 条会话`,
local: "本地会话",
list: "会话列表",
list: "文件夹与会话",
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
loaded: (count: number) => `当前加载 ${count} 条`,
search: "搜索标题 / 项目 / 供应商 / ID",
groupByProject: "按项目路径分组",
groupByProject: "文件夹视图",
showInternal: (count: number) => `显示内部会话 (${count})`,
deleteSelected: "删除选中",
exportSelected: "导出选中",
@@ -189,6 +243,12 @@ export function SessionManagementPage({
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
pinFolder: "置顶文件夹",
unpinFolder: "取消置顶文件夹",
pinSession: "置顶会话",
unpinSession: "取消置顶会话",
allSessions: "全部会话",
folders: "工作文件夹",
selectSession: "选择会话",
archived: "已归档",
internal: "内部",
@@ -198,6 +258,7 @@ export function SessionManagementPage({
noModel: "未记录",
noMatch: "没有匹配的会话。",
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
loadingMore: "正在加载更多会话…",
diagnostics: "诊断信息",
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
@@ -224,14 +285,14 @@ export function SessionManagementPage({
clickToCheck: "Check sessions to get started",
scanIncomplete: "Unable to verify sync status. See the reason below.",
needsSync: (count: number) => `${count} session(s) need syncing`,
allSynced: "User conversations are synced",
allSynced: "All sessions are synced",
sessionCount: (count: number) => `${count} sessions`,
local: "LOCAL SESSIONS",
list: "Sessions",
list: "Folders & sessions",
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
loaded: (count: number) => `${count} loaded`,
search: "Search title / project / provider / ID",
groupByProject: "Group by project path",
groupByProject: "Folder view",
showInternal: (count: number) => `Show internal sessions (${count})`,
deleteSelected: "Delete selected",
exportSelected: "Export selected",
@@ -243,6 +304,12 @@ export function SessionManagementPage({
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
pinFolder: "Pin folder",
unpinFolder: "Unpin folder",
pinSession: "Pin session",
unpinSession: "Unpin session",
allSessions: "All sessions",
folders: "Workspace folders",
selectSession: "Select session",
archived: "Archived",
internal: "Internal",
@@ -252,6 +319,7 @@ export function SessionManagementPage({
noModel: "Not recorded",
noMatch: "No matching sessions.",
noSessions: "No sessions loaded. Click Check sessions to refresh.",
loadingMore: "Loading more sessions…",
diagnostics: "Diagnostics",
diagnosticsCount: (count: number) => `${count} · click to view`,
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
@@ -273,10 +341,173 @@ export function SessionManagementPage({
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
];
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
const sessionScrollRef = useRef<HTMLDivElement | null>(null);
const directoryIdentities = sessionStatus?.directoryIdentities;
const pinScopeKey = sessionStatus?.pinScopeKey;
const pinStorageKey = useMemo(() => sessionPinStorageKey(codexDir, pinScopeKey), [codexDir, pinScopeKey]);
const [pinPreferences, setPinPreferences] = useState<ScopedSessionPinPreferences>({
storageKey: "",
folders: [],
sessions: [],
});
const [selectedFolderKey, setSelectedFolderKey] = useState(SESSION_ALL_FOLDERS_KEY);
const [sessionScrollMetrics, setSessionScrollMetrics] = useState({
scrollTop: 0,
viewportHeight: DEFAULT_SESSION_VIEWPORT_HEIGHT,
});
useEffect(() => {
setPinPreferences({ storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) });
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
}, [pinStorageKey]);
const activePinPreferences = pinPreferences.storageKey === pinStorageKey
? pinPreferences
: { storageKey: pinStorageKey, folders: [], sessions: [] };
const pinnedFolderSet = useMemo(() => pinnedSessionDirectoryKeys(activePinPreferences.folders, directoryIdentities), [activePinPreferences.folders, directoryIdentities]);
const pinnedSessionSet = useMemo(() => new Set(activePinPreferences.sessions), [activePinPreferences.sessions]);
const updatePins = useCallback((kind: "folders" | "sessions", key: string) => {
setPinPreferences((current) => {
const base = current.storageKey === pinStorageKey
? current
: { storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) };
const values = new Set(base.sessions);
if (kind === "sessions") {
if (values.has(key)) values.delete(key);
else values.add(key);
}
const next = kind === "folders"
? { ...base, folders: toggleFolderPins(base.folders, key, directoryIdentities) }
: { ...base, sessions: Array.from(values) };
try {
localStorage.setItem(pinStorageKey, JSON.stringify({ folders: next.folders, sessions: next.sessions }));
} catch {
// Pinning remains available for this run even when localStorage is unavailable.
}
return next;
});
}, [pinStorageKey, directoryIdentities]);
const orderedSessionGroups = useMemo(() => mergeSessionDirectoryGroups(groupedSessions, directoryIdentities).map(({ group, folderKey, items }) => {
const orderedItems = [...items].sort((left, right) => {
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
if (pinnedOrder) return pinnedOrder;
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
return recencyOrder || left.id.localeCompare(right.id);
});
return {
group,
folderKey,
items: orderedItems,
newestAt: orderedItems.reduce((latest, item) => Math.max(latest, item.updatedAtMs || 0), 0),
pinned: pinnedFolderSet.has(folderKey),
};
}).sort((left, right) => {
if (!sessionGroupByCwd) return 0;
const pinnedOrder = Number(right.pinned) - Number(left.pinned);
return pinnedOrder || right.newestAt - left.newestAt || left.group.localeCompare(right.group);
}), [groupedSessions, directoryIdentities, pinnedFolderSet, pinnedSessionSet, sessionGroupByCwd]);
const orderedAllSessions = useMemo(() => [...filteredSessions].sort((left, right) => {
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
if (pinnedOrder) return pinnedOrder;
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
return recencyOrder || left.id.localeCompare(right.id);
}), [filteredSessions, pinnedSessionSet]);
useEffect(() => {
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return;
if (!orderedSessionGroups.some((group) => group.folderKey === selectedFolderKey)) {
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
}
}, [orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
const displayedSessions = useMemo(() => {
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return orderedAllSessions;
return sessionsForDirectoryGroup(orderedSessionGroups, selectedFolderKey);
}, [orderedAllSessions, orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
const selectedVisibleCount = displayedSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const allVisibleSelected = displayedSessions.length > 0 && selectedVisibleCount === displayedSessions.length;
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
const virtualSessionModel = useMemo(() => {
const rows: SessionVirtualRow[] = [];
let top = 0;
displayedSessions.forEach((item) => {
rows.push({
kind: "session",
key: `session:${item.id}`,
top,
height: SESSION_ROW_HEIGHT,
item,
});
top += SESSION_ROW_HEIGHT;
});
return { rows, totalHeight: top };
}, [displayedSessions]);
const visibleSessionRows = useMemo(() => {
const viewportHeight = sessionScrollMetrics.viewportHeight || DEFAULT_SESSION_VIEWPORT_HEIGHT;
const bodyScrollTop = Math.max(0, sessionScrollMetrics.scrollTop - SESSION_COLUMN_HEADER_HEIGHT);
const firstVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop);
const lastVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop + viewportHeight);
const firstIndex = Math.max(0, firstVisibleIndex - SESSION_VIRTUAL_OVERSCAN_ROWS);
const lastIndex = Math.min(
virtualSessionModel.rows.length,
lastVisibleIndex + SESSION_VIRTUAL_OVERSCAN_ROWS + 1,
);
return virtualSessionModel.rows.slice(firstIndex, lastIndex);
}, [sessionScrollMetrics, virtualSessionModel]);
const syncSessionScrollMetrics = useCallback(() => {
const element = sessionScrollRef.current;
if (!element) return;
setSessionScrollMetrics((current) => {
const next = {
scrollTop: element.scrollTop,
viewportHeight: element.clientHeight,
};
return current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight
? current
: next;
});
}, []);
const handleSessionScroll = useCallback(() => {
syncSessionScrollMetrics();
}, [syncSessionScrollMetrics]);
useEffect(() => {
syncSessionScrollMetrics();
const element = sessionScrollRef.current;
if (!element) return undefined;
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", syncSessionScrollMetrics);
return () => window.removeEventListener("resize", syncSessionScrollMetrics);
}
const observer = new ResizeObserver(syncSessionScrollMetrics);
observer.observe(element);
return () => observer.disconnect();
}, [active, syncSessionScrollMetrics]);
useEffect(() => {
const element = sessionScrollRef.current;
if (!element) return;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
if (element.scrollTop > maxScrollTop) {
element.scrollTop = maxScrollTop;
syncSessionScrollMetrics();
}
}, [syncSessionScrollMetrics, virtualSessionModel.totalHeight]);
useEffect(() => {
const element = sessionScrollRef.current;
if (!element) return;
element.scrollTop = 0;
syncSessionScrollMetrics();
}, [selectedFolderKey, sessionGroupByCwd, syncSessionScrollMetrics]);
return (
<>
<ModalShell
@@ -429,56 +660,102 @@ export function SessionManagementPage({
</button>
</div>
{filteredSessions.length > 0 ? (
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
<div className="cx-session-column-head" role="row">
<Checkbox
className="cx-session-select-all"
checked={allVisibleSelected}
indeterminate={visibleSelectionIsPartial}
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
aria-label={copy.selectAll}
disabled={loading || sessionDeleteBusy}
/>
<span>{isChinese ? "会话" : "Session"}</span>
<span>{isChinese ? "更新时间" : "Updated"}</span>
<span>{isChinese ? "供应商" : "Provider"}</span>
<span>{isChinese ? "模型" : "Model"}</span>
<span className="cx-session-id-heading">ID</span>
<span className="cx-session-actions-heading">{isChinese ? "导出" : "Export"}</span>
</div>
<div className="cx-session-table-body">
{groupedSessions.map(([group, items]) => {
const projectSessions = allSessionsByCwd.get(group) || items;
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
const groupCountLabel = items.length === projectSessions.length
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
: copy.projectShown(items.length, projectSessions.length);
return (
<div className="cx-session-group" key={group}>
{sessionGroupByCwd && (
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
<input
className="cx-session-checkbox"
type="checkbox"
ref={(input) => {
if (input) input.indeterminate = projectPartiallySelected;
}}
checked={projectSelected}
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
aria-label={copy.selectProject(group, projectSessions.length)}
/>
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
<em>{groupCountLabel}</em>
</label>
)}
{items.map((item) => (
<div className={cx("cx-session-browser", !sessionGroupByCwd && "cx-session-browser--flat")}>
{sessionGroupByCwd && (
<aside className="cx-session-folder-tree" aria-label={copy.folders}>
<div className="cx-session-tree-heading">
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
<FolderTree size={15} strokeWidth={1.9} aria-hidden="true" />
<span>{copy.folders}</span>
</div>
<div className="cx-session-tree-items" role="tree">
<button
type="button"
role="treeitem"
aria-selected={selectedFolderKey === SESSION_ALL_FOLDERS_KEY}
className={cx("cx-session-tree-all", selectedFolderKey === SESSION_ALL_FOLDERS_KEY && "cx-session-tree-item--active")}
onClick={() => setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY)}
>
<History size={15} strokeWidth={1.9} aria-hidden="true" />
<span>{copy.allSessions}</span>
<em>{filteredSessions.length}</em>
</button>
{orderedSessionGroups.map((folder) => (
<div
className={cx("cx-session-tree-item", selectedFolderKey === folder.folderKey && "cx-session-tree-item--active")}
role="treeitem"
aria-selected={selectedFolderKey === folder.folderKey}
key={folder.folderKey}
>
<button
type="button"
className="cx-session-tree-select"
onClick={() => setSelectedFolderKey(folder.folderKey)}
title={folder.group}
>
<Folder size={15} strokeWidth={1.8} aria-hidden="true" />
<span className="cx-session-folder-copy">
<strong>{folderDisplayName(folder.group, isChinese ? "未记录路径" : "No path recorded")}</strong>
<small>{compactPath(folder.group, 34, isChinese ? "未记录路径" : "No path recorded")}</small>
</span>
<em>{folder.items.length}</em>
</button>
<button
type="button"
className={cx("cx-session-pin-action", folder.pinned && "cx-session-pin-action--active")}
onClick={() => updatePins("folders", folder.items.find((item) => item.cwd)?.cwd || "")}
aria-label={folder.pinned ? copy.unpinFolder : copy.pinFolder}
title={folder.pinned ? copy.unpinFolder : copy.pinFolder}
>
<Pin size={13} strokeWidth={1.9} fill={folder.pinned ? "currentColor" : "none"} aria-hidden="true" />
</button>
</div>
))}
</div>
</aside>
)}
<div className="cx-session-results">
{displayedSessions.length > 0 ? (
<div
ref={sessionScrollRef}
className="cx-session-scroll"
role="table"
aria-label={copy.list}
onScroll={handleSessionScroll}
>
<div className="cx-session-column-head" role="row">
<Checkbox
className="cx-session-select-all"
checked={allVisibleSelected}
indeterminate={visibleSelectionIsPartial}
onCheckedChange={(checked) => onSetSessionGroupSelected(displayedSessions, checked)}
aria-label={copy.selectAll}
disabled={loading || sessionDeleteBusy}
/>
<span>{isChinese ? "会话" : "Session"}</span>
<span>{isChinese ? "更新时间" : "Updated"}</span>
<span>{isChinese ? "供应商" : "Provider"}</span>
<span>{isChinese ? "模型" : "Model"}</span>
<span className="cx-session-id-heading">ID</span>
<span className="cx-session-actions-heading">{isChinese ? "操作" : "Actions"}</span>
</div>
<div className="cx-session-table-body" style={{ height: virtualSessionModel.totalHeight }}>
{visibleSessionRows.map((row) => {
const { item } = row;
const sessionPinned = pinnedSessionSet.has(item.id);
return (
<div
className={cx("cx-session-row", item.needsSync && !item.isSubagent && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")}
key={item.id}
className={cx(
"cx-session-row",
"cx-session-virtual-item",
item.needsSync && !item.isSubagent && "cx-session-row--needs-sync",
selectedSessionSet.has(item.id) && "cx-session-row--selected",
sessionPinned && "cx-session-row--pinned",
)}
key={row.key}
role="row"
style={{ top: row.top, height: row.height }}
onClick={(event) => {
if (!(event.target as HTMLElement).closest("button, input") && !loading && !sessionDeleteBusy) onToggleSessionSelected(item.id);
}}
@@ -500,35 +777,46 @@ export function SessionManagementPage({
{item.isSubagent && <span className="cx-session-state" title={copy.internalHint}>{copy.internal}</span>}
{item.needsSync && !item.isSubagent && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
</div>
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
{(!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
</div>
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
<button
type="button"
className="cx-session-row-export"
onClick={() => onExportSessions([item.id])}
disabled={loading || sessionDeleteBusy || sessionExportBusy}
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
title={copy.exportOne}
>
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
<div className="cx-session-row-actions">
<button
type="button"
className={cx("cx-session-pin-action", sessionPinned && "cx-session-pin-action--active")}
onClick={() => updatePins("sessions", item.id)}
aria-label={sessionPinned ? copy.unpinSession : copy.pinSession}
title={sessionPinned ? copy.unpinSession : copy.pinSession}
>
<Pin size={14} strokeWidth={1.9} fill={sessionPinned ? "currentColor" : "none"} aria-hidden="true" />
</button>
<button
type="button"
className="cx-session-row-export"
onClick={() => onExportSessions([item.id])}
disabled={loading || sessionDeleteBusy || sessionExportBusy}
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
title={copy.exportOne}
>
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
);
})}
</div>
);
})}
</div>
</div>
) : (
<div className="cx-session-empty">
<History size={22} strokeWidth={1.7} aria-hidden="true" />
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
</div>
)}
</div>
) : (
<div className="cx-session-empty">
<History size={22} strokeWidth={1.7} aria-hidden="true" />
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
</div>
)}
</div>
</div>
{diagnostics.length ? (
@@ -0,0 +1,80 @@
export const SESSION_FOLDER_UNKNOWN_KEY = "__codexx_no_workspace__";
const SESSION_PINS_STORAGE_PREFIX = "codexx.sessionPins.v2";
export type SessionDirectoryIdentities = Readonly<Record<string, string>>;
export type SessionPinPreferences = { folders: string[]; sessions: string[] };
export type SessionDirectoryGroup<T> = { group: string; folderKey: string; items: T[] };
/** Only the backend can confirm that differently spelled paths name one directory. */
export function sessionDirectoryKey(
path?: string | null,
identities?: SessionDirectoryIdentities,
): string {
if (path == null || path === "") return SESSION_FOLDER_UNKNOWN_KEY;
if (identities && Object.prototype.hasOwnProperty.call(identities, path)) {
return identities[path];
}
// Missing/offline directories keep their exact spelling. Case, backslashes,
// roots and spaces can all distinguish legitimate paths on some filesystems.
return `path:${path}`;
}
export function sessionPinStorageKey(codexDir: string, verifiedScope?: string | null) {
// v1 discarded case and cannot safely assign its pins to a particular home.
// A snapshot's file ID alone is not a permanent home identity after deletion.
return `${SESSION_PINS_STORAGE_PREFIX}:${encodeURIComponent(verifiedScope || sessionDirectoryKey(codexDir))}`;
}
export function pinnedSessionDirectoryKeys(folders: readonly string[], identities?: SessionDirectoryIdentities): Set<string> {
const keys = new Set<string>();
for (const stored of folders) {
if (stored === SESSION_FOLDER_UNKNOWN_KEY) keys.add(stored);
else if (stored.startsWith("path:")) keys.add(sessionDirectoryKey(stored.slice(5), identities));
}
return keys;
}
/** Persist original paths so offline/recreated folders retain their pins. */
export function toggleFolderPins(
folders: readonly string[],
path?: string | null,
identities?: SessionDirectoryIdentities,
): string[] {
const currentKey = sessionDirectoryKey(path, identities);
const belongsToCurrentFolder = (stored: string) => pinnedSessionDirectoryKeys([stored], identities).has(currentKey);
if (folders.some(belongsToCurrentFolder)) return folders.filter((stored) => !belongsToCurrentFolder(stored));
return [...new Set([...folders, sessionDirectoryKey(path)])];
}
export function readSessionPinPreferences(
storageKey: string,
storage: Pick<Storage, "getItem">,
): SessionPinPreferences {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "{}") as Partial<SessionPinPreferences>;
return {
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((value): value is string => typeof value === "string") : [],
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter((value): value is string => typeof value === "string") : [],
};
} catch {
return { folders: [], sessions: [] };
}
}
export function mergeSessionDirectoryGroups<T extends { cwd?: string | null }>(
groups: ReadonlyArray<readonly [string, readonly T[]]>,
identities?: SessionDirectoryIdentities,
): SessionDirectoryGroup<T>[] {
const result = new Map<string, SessionDirectoryGroup<T>>();
for (const [group, items] of groups) {
const folderKey = sessionDirectoryKey(items.find((item) => item.cwd)?.cwd, identities);
const existing = result.get(folderKey);
if (existing) existing.items.push(...items);
else result.set(folderKey, { group, folderKey, items: [...items] });
}
return [...result.values()];
}
export function sessionsForDirectoryGroup<T>(groups: ReadonlyArray<SessionDirectoryGroup<T>>, key: string): T[] {
return groups.find((group) => group.folderKey === key)?.items || [];
}
+46
View File
@@ -603,10 +603,56 @@
background-clip: padding-box;
}
:root[data-theme="dark"] .cx-session-folder-tree,
:root[data-theme="dark"] .cx-session-tree-heading {
color: var(--cx-dark-muted);
background: var(--cx-dark-surface-raised);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-session-results {
background: var(--cx-dark-surface);
}
:root[data-theme="dark"] .cx-session-tree-all,
:root[data-theme="dark"] .cx-session-tree-item {
color: var(--cx-dark-muted);
}
:root[data-theme="dark"] .cx-session-tree-all:hover,
:root[data-theme="dark"] .cx-session-tree-item:hover {
color: var(--cx-dark-text);
background: var(--cx-dark-surface-hover);
}
:root[data-theme="dark"] .cx-session-tree-item--active {
color: var(--cx-dark-blue);
background: var(--cx-dark-blue-surface);
border-color: #5879aa;
}
:root[data-theme="dark"] .cx-session-tree-item--active .cx-session-folder-copy strong {
color: var(--cx-dark-blue);
}
:root[data-theme="dark"] .cx-session-group + .cx-session-group {
border-top-color: var(--cx-dark-surface-recessed);
}
:root[data-theme="dark"] .cx-session-group-heading--selected,
:root[data-theme="dark"] .cx-session-group-heading--partial {
color: var(--cx-dark-blue);
background: var(--cx-dark-blue-surface);
}
:root[data-theme="dark"] .cx-session-folder-copy strong {
color: var(--cx-dark-text);
}
:root[data-theme="dark"] .cx-session-folder-copy small {
color: var(--cx-dark-muted);
}
:root[data-theme="dark"] .cx-session-row {
background: var(--cx-dark-surface);
border-color: var(--cx-dark-border);
+269 -11
View File
@@ -387,6 +387,146 @@
flex: 0 0 auto;
}
.cx-session-browser {
min-width: 0;
min-height: 0;
height: 0;
flex: 1 1 0;
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
overflow: hidden;
}
.cx-session-browser--flat {
grid-template-columns: minmax(0, 1fr);
}
.cx-session-folder-tree {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid #e6e9ed;
background: #f8f9fb;
}
.cx-session-tree-heading {
min-height: 34px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 11px;
border-bottom: 1px solid #e6e9ed;
color: #767e89;
background: #fafbfc;
font-size: 10px;
font-weight: 720;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cx-session-tree-items {
min-height: 0;
display: grid;
align-content: start;
gap: 2px;
overflow-y: auto;
padding: 7px;
scrollbar-gutter: stable;
}
.cx-session-tree-all,
.cx-session-tree-item {
min-width: 0;
min-height: 36px;
border: 1px solid transparent;
border-radius: 7px;
color: #626a75;
background: transparent;
}
.cx-session-tree-all {
width: 100%;
display: grid;
grid-template-columns: 17px minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
padding: 0 8px;
font-size: 11px;
font-weight: 690;
text-align: left;
cursor: pointer;
}
.cx-session-tree-all em,
.cx-session-tree-select em {
color: #969da7;
font-size: 10px;
font-style: normal;
font-weight: 650;
}
.cx-session-tree-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 27px;
align-items: center;
}
.cx-session-tree-select {
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 17px minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
padding: 4px 3px 4px 8px;
overflow: hidden;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.cx-session-tree-item > .cx-session-pin-action {
width: 25px;
height: 25px;
border-color: transparent;
background: transparent;
}
.cx-session-tree-all:hover,
.cx-session-tree-item:hover {
color: #354052;
background: #f0f3f7;
}
.cx-session-tree-item--active {
border-color: #cfdfff;
color: #2454b3;
background: #edf4ff;
}
.cx-session-tree-item--active .cx-session-folder-copy strong {
color: #2454b3;
}
.cx-session-tree-all:focus-visible,
.cx-session-tree-select:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: -2px;
}
.cx-session-results {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
}
.cx-session-scroll {
min-width: 0;
min-height: 0;
@@ -426,7 +566,7 @@
.cx-session-column-head,
.cx-session-row {
display: grid;
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 34px;
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 70px;
gap: 10px;
align-items: center;
}
@@ -456,9 +596,16 @@
}
.cx-session-table-body {
position: relative;
min-width: 0;
}
.cx-session-virtual-item {
position: absolute;
right: 0;
left: 0;
}
.cx-session-group {
min-width: 0;
}
@@ -470,17 +617,20 @@
.cx-session-group-heading {
min-width: 0;
display: grid;
grid-template-columns: 26px minmax(0, 1fr) auto;
grid-template-columns: 26px minmax(0, 1fr) auto 30px;
gap: 10px;
align-items: center;
min-height: 36px;
min-height: 44px;
padding: 0 12px;
border-bottom: 1px solid #eef0f2;
color: #737b86;
background: #fcfcfd;
font-size: 11px;
font-weight: 680;
cursor: pointer;
}
.cx-session-group-heading--separated {
border-top: 7px solid #f7f8f9;
}
.cx-session-group-heading--selected,
@@ -489,13 +639,61 @@
background: #f5f8ff;
}
.cx-session-group-heading > span {
.cx-session-group-heading--pinned {
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 72%, transparent);
}
.cx-session-folder-toggle {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
padding: 0;
overflow: hidden;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.cx-session-folder-toggle > svg {
flex: 0 0 auto;
}
.cx-session-folder-copy {
min-width: 0;
display: grid;
gap: 1px;
}
.cx-session-folder-copy strong,
.cx-session-folder-copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-session-folder-copy strong {
color: #3f4650;
font-size: 11.5px;
font-weight: 720;
}
.cx-session-folder-copy small {
color: #9299a3;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9.5px;
font-weight: 500;
}
.cx-session-folder-toggle:focus-visible {
border-radius: 4px;
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.cx-session-group-heading em {
color: #969ca5;
font-size: 10px;
@@ -534,6 +732,14 @@
background: #edf4ff;
}
.cx-session-row--pinned:not(.cx-session-row--selected) {
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 52%, transparent);
}
.cx-session-row--nested .cx-session-row-copy {
padding-left: 4px;
}
.cx-session-row:focus-within {
outline: 1px solid rgba(37, 99, 235, 0.38);
outline-offset: -1px;
@@ -664,7 +870,15 @@
color: #4669a8;
}
.cx-session-row-export {
.cx-session-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.cx-session-row-export,
.cx-session-pin-action {
width: 30px;
height: 30px;
justify-self: end;
@@ -678,22 +892,44 @@
cursor: pointer;
}
.cx-session-row-export:hover:not(:disabled) {
.cx-session-row-export:hover:not(:disabled),
.cx-session-pin-action:hover:not(:disabled) {
color: var(--ui-accent);
border-color: var(--ui-accent);
background: var(--ui-accent-soft);
}
.cx-session-row-export:focus-visible {
.cx-session-row-export:focus-visible,
.cx-session-pin-action:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.cx-session-row-export:disabled {
.cx-session-row-export:disabled,
.cx-session-pin-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cx-session-pin-action--active {
color: var(--ui-accent);
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--ui-border));
background: var(--ui-accent-soft);
}
.cx-session-load-more {
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 12px;
border-top: 1px solid #eef0f2;
color: #7e8793;
background: #fafbfc;
font-size: 11px;
}
.cx-session-empty {
min-height: 120px;
flex: 1 1 auto;
@@ -911,9 +1147,17 @@
}
@media (max-width: 1240px) {
.cx-session-browser {
grid-template-columns: 210px minmax(0, 1fr);
}
.cx-session-browser--flat {
grid-template-columns: minmax(0, 1fr);
}
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 34px;
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 70px;
}
.cx-session-id-heading,
@@ -957,9 +1201,23 @@
flex: 1 1 auto;
}
.cx-session-browser {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(110px, 30%) minmax(0, 1fr);
}
.cx-session-browser--flat {
grid-template-rows: minmax(0, 1fr);
}
.cx-session-folder-tree {
border-right: 0;
border-bottom: 1px solid #e6e9ed;
}
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(0, 1fr) 82px 34px;
grid-template-columns: 26px minmax(0, 1fr) 82px 70px;
}
.cx-session-column-head span:nth-child(n + 4):not(.cx-session-actions-heading),
@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
SESSION_FOLDER_UNKNOWN_KEY,
mergeSessionDirectoryGroups,
pinnedSessionDirectoryKeys,
readSessionPinPreferences,
sessionDirectoryKey,
sessionPinStorageKey,
sessionsForDirectoryGroup,
toggleFolderPins,
} from "../src/sessionDirectoryIdentity.ts";
const sessionIds = (items) => items.map((item) => item.id);
test("case-sensitive directories have unique folder identities and accurate selection", () => {
const upper = "/case-sensitive/Foo";
const lower = "/case-sensitive/foo";
const identities = { [upper]: "verified:upper", [lower]: "verified:lower" };
const groups = mergeSessionDirectoryGroups([
[upper, [{ id: "upper-one", cwd: upper }, { id: "upper-two", cwd: upper }]],
[lower, [{ id: "lower-one", cwd: lower }, { id: "lower-two", cwd: lower }]],
], identities);
assert.equal(groups.length, 2);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2, "React folder keys must be unique");
assert.notEqual(sessionDirectoryKey(upper, identities), sessionDirectoryKey(lower, identities));
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(upper, identities))), ["upper-one", "upper-two"]);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower, identities))), ["lower-one", "lower-two"]);
});
test("case-distinct directories stay separate before filesystem identities are available", () => {
const upper = "/offline/Foo";
const lower = "/offline/foo";
const groups = mergeSessionDirectoryGroups([
[upper, [{ id: "upper", cwd: upper }]],
[lower, [{ id: "lower", cwd: lower }]],
]);
assert.equal(groups.length, 2);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower))), ["lower"]);
assert.deepEqual(sessionsForDirectoryGroup(groups, "unknown-selection"), []);
});
test("filesystem-verified aliases merge into one folder containing every spelling's sessions", () => {
const root = mkdtempSync(join(tmpdir(), "codex-x-directory-identity-"));
try {
const target = join(root, "Workspace");
const alias = join(root, "Workspace-alias");
mkdirSync(target);
symlinkSync(target, alias, process.platform === "win32" ? "junction" : "dir");
const verifiedKey = (path) => {
const metadata = statSync(path, { bigint: true });
assert(metadata.isDirectory());
return `verified:${metadata.dev}:${metadata.ino}:${realpathSync.native(path)}`;
};
const identities = { [target]: verifiedKey(target), [alias]: verifiedKey(alias) };
assert.equal(identities[target], identities[alias], "the temporary filesystem must confirm the alias");
const original = Object.freeze([
Object.freeze([target, Object.freeze([{ id: "target-one", cwd: target }, { id: "target-two", cwd: target }])]),
Object.freeze([alias, Object.freeze([{ id: "alias-one", cwd: alias }])]),
]);
const groups = mergeSessionDirectoryGroups(original, identities);
assert.equal(groups.length, 1);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 1);
assert.equal(groups[0].group, target, "the first folder label is retained");
for (const spelling of [target, alias]) {
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(spelling, identities))), ["target-one", "target-two", "alias-one"]);
}
assert.equal(original[0][1].length, 2, "merging must not mutate parent session arrays");
assert.equal(original[1][1].length, 1);
assert.equal(sessionPinStorageKey(target, identities[target]), sessionPinStorageKey(alias, identities[alias]));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("unresolved paths preserve case, backslashes, trailing spaces and roots", () => {
const paths = [
"/missing/Foo", "/missing/foo", "/missing/Foo/",
"/missing/name", "/missing/name ", "/missing/name ",
"/missing/with\\backslash", "/missing/with/backslash",
"/", "//", "C:\\", "C:/", " ",
SESSION_FOLDER_UNKNOWN_KEY,
];
const keys = paths.map((path) => sessionDirectoryKey(path));
assert.equal(new Set(keys).size, paths.length);
for (const path of paths) {
assert.equal(sessionDirectoryKey(path), sessionDirectoryKey(path, {}));
assert.notEqual(sessionDirectoryKey(path), SESSION_FOLDER_UNKNOWN_KEY);
}
for (const missing of [undefined, null, ""]) {
assert.equal(sessionDirectoryKey(missing), SESSION_FOLDER_UNKNOWN_KEY);
}
assert.notEqual(sessionDirectoryKey("/"), sessionDirectoryKey(null));
});
test("a nonempty raw path cannot borrow an inherited identity-table property", () => {
const identities = Object.create({ "offline/Foo": "verified:unrelated" });
assert.equal(sessionDirectoryKey("offline/Foo", identities), sessionDirectoryKey("offline/Foo"));
assert.notEqual(sessionDirectoryKey("offline/Foo", identities), "verified:unrelated");
});
test("directory pins remain independent for case-distinct folders", () => {
const identities = { "/work/Foo": "verified:Foo", "/work/foo": "verified:foo" };
const upper = sessionDirectoryKey("/work/Foo", identities);
const lower = sessionDirectoryKey("/work/foo", identities);
let stored = toggleFolderPins([], "/work/Foo", identities);
assert.deepEqual(stored, ["path:/work/Foo"], "persistent pins must retain the original path");
assert(pinnedSessionDirectoryKeys(stored, identities).has(upper));
assert(!pinnedSessionDirectoryKeys(stored, identities).has(lower));
stored = toggleFolderPins(stored, "/work/foo", identities);
assert.deepEqual(stored, ["path:/work/Foo", "path:/work/foo"]);
stored = toggleFolderPins(stored, "/work/Foo", identities);
assert.deepEqual(stored, ["path:/work/foo"]);
assert(!pinnedSessionDirectoryKeys(stored, identities).has(upper));
assert(pinnedSessionDirectoryKeys(stored, identities).has(lower));
assert.deepEqual(toggleFolderPins(stored, "/work/foo", identities), []);
});
test("persistent raw-path pins survive online, offline and recreated directory identities", () => {
const path = "/work/Project ";
const online = { [path]: "verified:original-inode" };
const recreated = { [path]: "verified:new-inode" };
const stored = Object.freeze(toggleFolderPins([], path, online));
assert.deepEqual(stored, ["path:/work/Project "]);
for (const identities of [online, undefined, {}, recreated]) {
assert(pinnedSessionDirectoryKeys(stored, identities).has(sessionDirectoryKey(path, identities)));
assert.deepEqual(toggleFolderPins(stored, path, identities), []);
}
assert(!pinnedSessionDirectoryKeys(stored, recreated).has(online[path]));
assert.deepEqual(stored, ["path:/work/Project "], "resolving or toggling must not mutate the saved pins");
});
test("unpinning a verified alias removes every matching raw-path pin and keeps unrelated pins", () => {
const target = "/work/Project";
const alias = "/links/Project";
const other = "/work/Other";
const identities = { [target]: "verified:shared", [alias]: "verified:shared", [other]: "verified:other" };
const stored = Object.freeze(["path:/work/Project", "path:/links/Project", "path:/work/Other"]);
for (const spelling of [target, alias]) {
const next = toggleFolderPins(stored, spelling, identities);
assert.deepEqual(next, ["path:/work/Other"]);
assert(!pinnedSessionDirectoryKeys(next, identities).has(identities[target]));
assert(pinnedSessionDirectoryKeys(next, identities).has(identities[other]));
}
assert.deepEqual(toggleFolderPins([], alias, identities), ["path:/links/Project"]);
assert.deepEqual(toggleFolderPins(["path:/work/Project"], alias, identities), []);
assert.equal(stored.length, 3);
});
test("case-distinct CODEX_HOME values have independent v2 pin storage", () => {
const upper = "/homes/Foo";
const lower = "/homes/foo";
for (const identities of [undefined, { [upper]: "verified:home-upper", [lower]: "verified:home-lower" }]) {
const upperKey = sessionPinStorageKey(upper, identities?.[upper]);
const lowerKey = sessionPinStorageKey(lower, identities?.[lower]);
assert.notEqual(upperKey, lowerKey);
assert(upperKey.startsWith("codexx.sessionPins.v2:"));
assert(lowerKey.startsWith("codexx.sessionPins.v2:"));
const cache = new Map([
[upperKey, JSON.stringify({ folders: ["upper-folder"], sessions: ["upper-session"] })],
[lowerKey, JSON.stringify({ folders: ["lower-folder"], sessions: ["lower-session"] })],
]);
const storage = { getItem: (key) => cache.get(key) ?? null };
assert.deepEqual(readSessionPinPreferences(upperKey, storage), { folders: ["upper-folder"], sessions: ["upper-session"] });
assert.deepEqual(readSessionPinPreferences(lowerKey, storage), { folders: ["lower-folder"], sessions: ["lower-session"] });
}
});
test("an unverified home scope falls back to case-preserving paths without borrowing snapshot IDs", () => {
const upper = "/homes/Foo";
const lower = "/homes/foo";
const snapshotIds = { [upper]: "snapshot:reused-file-id", [lower]: "snapshot:reused-file-id" };
assert.equal(sessionDirectoryKey(upper, snapshotIds), sessionDirectoryKey(lower, snapshotIds));
const upperKey = sessionPinStorageKey(upper);
const lowerKey = sessionPinStorageKey(lower);
assert.notEqual(upperKey, lowerKey, "snapshot identities alone must not merge persistent home scopes");
assert.equal(sessionPinStorageKey(upper, null), upperKey);
assert.equal(sessionPinStorageKey(lower, null), lowerKey);
const cache = new Map([[upperKey, JSON.stringify({ folders: ["path:/work/Foo"], sessions: ["upper-only"] })]]);
assert.deepEqual(readSessionPinPreferences(lowerKey, { getItem: (key) => cache.get(key) ?? null }), { folders: [], sessions: [] });
});
test("reused physical home IDs with different verified birth scopes have isolated caches", () => {
const home = "/homes/Project";
const oldScope = "verified-home:device-1:file-42:birth-100";
const newScope = "verified-home:device-1:file-42:birth-200";
const oldKey = sessionPinStorageKey(home, oldScope);
const newKey = sessionPinStorageKey(home, newScope);
assert.notEqual(oldKey, newKey);
const cache = new Map([[oldKey, JSON.stringify({ folders: ["path:/work/old"], sessions: ["old-generation"] })]]);
const storage = { getItem: (key) => cache.get(key) ?? null };
assert.deepEqual(readSessionPinPreferences(oldKey, storage), { folders: ["path:/work/old"], sessions: ["old-generation"] });
assert.deepEqual(readSessionPinPreferences(newKey, storage), { folders: [], sessions: [] });
});
test("ambiguous legacy v1 pins never populate either case's v2 preferences", () => {
const homes = ["/homes/Foo", "/homes/foo"];
const legacyKey = "codexx.sessionPins.v1:%2Fhomes%2Ffoo";
const cache = new Map([[legacyKey, JSON.stringify({ folders: ["/work/foo"], sessions: ["legacy-session"] })]]);
const reads = [];
const storage = { getItem: (key) => { reads.push(key); return cache.get(key) ?? null; } };
for (const home of homes) {
const currentKey = sessionPinStorageKey(home);
assert.notEqual(currentKey, legacyKey);
assert.deepEqual(readSessionPinPreferences(currentKey, storage), { folders: [], sessions: [] });
}
assert.equal(new Set(reads).size, 2);
assert(reads.every((key) => key.startsWith("codexx.sessionPins.v2:")));
assert(!reads.includes(legacyKey));
});
test("unavailable storage readers return empty pin preferences", () => {
const storageKey = sessionPinStorageKey("/homes/Project");
const denied = { getItem() { throw new Error("fixture storage unavailable"); } };
assert.deepEqual(readSessionPinPreferences(storageKey, denied), { folders: [], sessions: [] });
const deniedGetter = { get getItem() { throw new Error("fixture storage getter unavailable"); } };
assert.deepEqual(readSessionPinPreferences(storageKey, deniedGetter), { folders: [], sessions: [] });
});
test("missing-workspace sessions and root-directory sessions select separate folders", () => {
const groups = mergeSessionDirectoryGroups([
["No workspace", [{ id: "missing-one", cwd: null }, { id: "missing-two" }]],
["/", [{ id: "root", cwd: "/" }]],
]);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, SESSION_FOLDER_UNKNOWN_KEY)), ["missing-one", "missing-two"]);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey("/"))), ["root"]);
});
test("missing-workspace and root-directory pins stay independent and persist through identity changes", () => {
const rootIdentity = { "/": "verified:root" };
let stored = toggleFolderPins([], null, rootIdentity);
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY]);
stored = toggleFolderPins(stored, "/", rootIdentity);
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY, "path:/"]);
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(SESSION_FOLDER_UNKNOWN_KEY));
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(rootIdentity["/"]));
assert(pinnedSessionDirectoryKeys(stored).has(sessionDirectoryKey("/")));
stored = toggleFolderPins(stored, undefined, rootIdentity);
assert.deepEqual(stored, ["path:/"]);
assert.deepEqual(toggleFolderPins(stored, "/", rootIdentity), []);
});
+3
View File
@@ -27,6 +27,9 @@
已安装 App 的旧目录迁移和独立数据库启动通过;原生 App 验证模型别名及 ID、跨来源上下拖动、SQLite
写入失败回滚和退出重启后的顺序保留,明暗主题显示正常。测试使用独立配置和数据库,真实配置/认证摘要一致。
- 用户已批准将本批修改作为 v0.3.22 推送,并由既有 Actions 完成跨平台打包、验签与发布。
- [v0.3.22 发布流水线](https://github.com/yynxxxxx/Codex-X/actions/runs/36705339645) 的四平台与最终发布验证全部成功;
Windows 全量 Rust 及 NSIS/旧 MSI 安装迁移通过,两架构 macOS 签名、公证与 DMG 校验通过。
公开更新清单的 11 个平台项验证完整,正式 Apple Silicon 更新包摘要、签名与公证票据核对通过,本地已换成正式 v0.3.22。
## 2026-09-22:Windows 更新改为用户安装并支持无重启迁移
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.22",
"version": "0.3.23",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",