M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{File, Metadata};
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use kigi_sqlite_journal::JournalMode;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ApprovedRoot {
|
||||
path: PathBuf,
|
||||
directory: Arc<File>,
|
||||
}
|
||||
|
||||
pub(super) struct OpenedRegularFile {
|
||||
pub file: File,
|
||||
pub path: PathBuf,
|
||||
pub metadata: Metadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct DirectoryVisit {
|
||||
pub visited: usize,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
pub(super) struct ReadTransactionSqlite {
|
||||
connection: Connection,
|
||||
#[allow(dead_code)]
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl ReadTransactionSqlite {
|
||||
fn new(connection: Connection, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ReadTransactionSqlite {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.connection
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReadTransactionSqlite {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.connection.execute_batch("ROLLBACK");
|
||||
}
|
||||
}
|
||||
|
||||
impl ApprovedRoot {
|
||||
pub fn new(path: &Path) -> Option<Self> {
|
||||
let path = dunce::canonicalize(path).ok()?;
|
||||
#[cfg(unix)]
|
||||
let directory = unix::open_directory_path(&path)?;
|
||||
#[cfg(windows)]
|
||||
let directory = windows::open_directory_path(&path)?;
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let directory: File = return None;
|
||||
|
||||
let metadata = directory.metadata().ok()?;
|
||||
if !metadata.is_dir() || has_reparse_point(&metadata) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if !windows::directory_path_matches(&path, &directory) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
path,
|
||||
directory: Arc::new(directory),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
|
||||
self.path.join(path)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn modified(&self) -> Option<SystemTime> {
|
||||
self.directory.metadata().ok()?.modified().ok()
|
||||
}
|
||||
|
||||
pub fn subroot(&self, path: &Path) -> Option<Self> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let relative = self.relative_path(path)?;
|
||||
let directory = unix::open_directory_relative(&self.directory, &relative)?;
|
||||
let metadata = directory.metadata().ok()?;
|
||||
if !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
path: self.path.join(relative),
|
||||
directory: Arc::new(directory),
|
||||
})
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_each_entry(&self, visit: impl FnMut(OsString)) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix::visit_directory_names(&self.directory, visit)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = visit;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_each_entry_bounded(
|
||||
&self,
|
||||
max_entries: usize,
|
||||
visit: impl FnMut(OsString),
|
||||
) -> DirectoryVisit {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix::visit_directory_names_bounded(&self.directory, max_entries, visit)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (max_entries, visit);
|
||||
DirectoryVisit {
|
||||
visited: 0,
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_path(&self, path: &Path) -> Option<PathBuf> {
|
||||
let relative = if path.is_absolute() {
|
||||
path.strip_prefix(&self.path).ok()?
|
||||
} else {
|
||||
path
|
||||
};
|
||||
relative
|
||||
.components()
|
||||
.all(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::Normal(_) | std::path::Component::CurDir
|
||||
)
|
||||
})
|
||||
.then(|| relative.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn resolve_regular_file(&self, path: &Path) -> Option<(PathBuf, Metadata)> {
|
||||
let opened = self.open_regular_file(path)?;
|
||||
Some((opened.path, opened.metadata))
|
||||
}
|
||||
|
||||
pub fn open_regular_file(&self, path: &Path) -> Option<OpenedRegularFile> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let relative = self.relative_path(path)?;
|
||||
let file = unix::open_regular_relative(&self.directory, &relative)?;
|
||||
let metadata = file.metadata().ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
Some(OpenedRegularFile {
|
||||
file,
|
||||
path: self.path.join(relative),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.join(path)
|
||||
};
|
||||
let parent = dunce::canonicalize(path.parent()?).ok()?;
|
||||
if !parent.starts_with(&self.path) {
|
||||
return None;
|
||||
}
|
||||
let path = parent.join(path.file_name()?);
|
||||
let expected = std::fs::symlink_metadata(&path).ok()?;
|
||||
if !expected.is_file()
|
||||
|| expected.file_type().is_symlink()
|
||||
|| has_reparse_point(&expected)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let canonical_path = dunce::canonicalize(&path).ok()?;
|
||||
if canonical_path.parent() != Some(parent.as_path())
|
||||
|| !canonical_path.starts_with(&self.path)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let file = windows::open_regular_path(&canonical_path)?;
|
||||
let metadata = file.metadata().ok()?;
|
||||
if !metadata.is_file()
|
||||
|| has_reparse_point(&metadata)
|
||||
|| !windows::canonical_file_matches(&canonical_path, &file)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
return Some(OpenedRegularFile {
|
||||
file,
|
||||
path: canonical_path,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_sqlite_transaction(
|
||||
root: &ApprovedRoot,
|
||||
path: &Path,
|
||||
) -> Option<ReadTransactionSqlite> {
|
||||
open_sqlite_transaction_with_journal_mode(root, path, JournalMode::for_db_path(path))
|
||||
}
|
||||
|
||||
fn open_sqlite_transaction_with_journal_mode(
|
||||
root: &ApprovedRoot,
|
||||
path: &Path,
|
||||
journal_mode: JournalMode,
|
||||
) -> Option<ReadTransactionSqlite> {
|
||||
match journal_mode {
|
||||
JournalMode::Wal => {}
|
||||
JournalMode::Truncate => return None,
|
||||
}
|
||||
let opened = root.open_regular_file(path)?;
|
||||
// These are same-user application stores. Canonical containment and a
|
||||
// non-symlink final file are validated above; adversarial swap-and-restore
|
||||
// races are outside this scanner's local-user threat model. Only local WAL
|
||||
// reaches this direct read-only/query-only open; its native coordination may
|
||||
// still update SHM read marks despite scanner SQL making no logical writes.
|
||||
let connection = Connection::open_with_flags(
|
||||
&opened.path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX
|
||||
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
|
||||
)
|
||||
.ok()?;
|
||||
let _ = connection.busy_timeout(Duration::from_millis(50));
|
||||
connection
|
||||
.execute_batch("PRAGMA query_only=ON; BEGIN DEFERRED")
|
||||
.ok()?;
|
||||
connection
|
||||
.query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.ok()?;
|
||||
Some(ReadTransactionSqlite::new(connection, opened.metadata))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn has_reparse_point(metadata: &Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt as _;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn has_reparse_point(_metadata: &Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,224 @@
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsString;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::windows;
|
||||
use super::*;
|
||||
use crate::foreign_sessions::canonical_tempdir;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn retained_directory_capability_survives_path_replacement() {
|
||||
use std::io::Read as _;
|
||||
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let original = root.join("sessions");
|
||||
std::fs::create_dir_all(&original).unwrap();
|
||||
std::fs::write(original.join("inside"), "inside").unwrap();
|
||||
std::fs::write(outside.path().join("outside"), "outside").unwrap();
|
||||
let approved = ApprovedRoot::new(&root).unwrap();
|
||||
let retained = approved.subroot(&original).unwrap();
|
||||
|
||||
let moved = root.join("sessions-original");
|
||||
std::fs::rename(&original, &moved).unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), &original).unwrap();
|
||||
|
||||
let mut names = Vec::new();
|
||||
assert!(retained.for_each_entry(|name| names.push(name)));
|
||||
assert!(names.contains(&OsString::from("inside")));
|
||||
assert!(!names.contains(&OsString::from("outside")));
|
||||
let mut contents = String::new();
|
||||
retained
|
||||
.open_regular_file(&retained.join("inside"))
|
||||
.unwrap()
|
||||
.file
|
||||
.read_to_string(&mut contents)
|
||||
.unwrap();
|
||||
assert_eq!(contents, "inside");
|
||||
assert!(
|
||||
retained
|
||||
.open_regular_file(&retained.join("outside"))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bounded_directory_visit_reports_exact_cutoff() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for index in 0..5 {
|
||||
std::fs::write(root.path().join(format!("entry-{index}")), "").unwrap();
|
||||
}
|
||||
let approved = ApprovedRoot::new(root.path()).unwrap();
|
||||
let mut visited = Vec::new();
|
||||
let outcome = approved.for_each_entry_bounded(3, |name| visited.push(name));
|
||||
assert_eq!(visited.len(), 3);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
DirectoryVisit {
|
||||
visited: 3,
|
||||
complete: false,
|
||||
}
|
||||
);
|
||||
|
||||
let small = tempfile::tempdir().unwrap();
|
||||
std::fs::write(small.path().join("a"), "").unwrap();
|
||||
std::fs::write(small.path().join("b"), "").unwrap();
|
||||
let approved = ApprovedRoot::new(small.path()).unwrap();
|
||||
let mut visited = 0;
|
||||
assert_eq!(
|
||||
approved.for_each_entry_bounded(3, |_| visited += 1),
|
||||
DirectoryVisit {
|
||||
visited: 2,
|
||||
complete: true,
|
||||
}
|
||||
);
|
||||
|
||||
let exact = tempfile::tempdir().unwrap();
|
||||
for index in 0..3 {
|
||||
std::fs::write(exact.path().join(format!("entry-{index}")), "").unwrap();
|
||||
}
|
||||
let approved = ApprovedRoot::new(exact.path()).unwrap();
|
||||
let mut visited = 0;
|
||||
assert_eq!(
|
||||
approved.for_each_entry_bounded(3, |_| visited += 1),
|
||||
DirectoryVisit {
|
||||
visited: 3,
|
||||
complete: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(visited, 3);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nonblocking_open_rejects_fifo() {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let fifo = root.path().join("metadata");
|
||||
let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
|
||||
// SAFETY: the path is NUL-terminated and points into the live tempdir.
|
||||
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
|
||||
let approved = ApprovedRoot::new(root.path()).unwrap();
|
||||
assert!(approved.open_regular_file(&fifo).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_truncate_mode_skips_foreign_database_without_mutation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("state.db");
|
||||
let connection = rusqlite::Connection::open(&path).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE values_for_test (value INTEGER);
|
||||
INSERT INTO values_for_test VALUES (42);",
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
let contents = std::fs::read(&path).unwrap();
|
||||
|
||||
let approved = ApprovedRoot::new(root.path()).unwrap();
|
||||
assert!(
|
||||
open_sqlite_transaction_with_journal_mode(&approved, &path, JournalMode::Truncate)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), contents);
|
||||
|
||||
let connection =
|
||||
rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.unwrap();
|
||||
let journal_mode: String = connection
|
||||
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(journal_mode, "wal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_wal_mode_queries_and_pins_snapshot() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let path = root.join("state.db");
|
||||
let writer = rusqlite::Connection::open(&path).unwrap();
|
||||
writer
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA wal_autocheckpoint=0;
|
||||
CREATE TABLE values_for_test (value INTEGER);
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
INSERT INTO values_for_test VALUES (42);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let approved = ApprovedRoot::new(&root).unwrap();
|
||||
let database =
|
||||
open_sqlite_transaction_with_journal_mode(&approved, &path, JournalMode::Wal).unwrap();
|
||||
let value: i64 = database
|
||||
.query_row("SELECT value FROM values_for_test", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(value, 42);
|
||||
writer
|
||||
.execute("INSERT INTO values_for_test VALUES (43)", [])
|
||||
.unwrap();
|
||||
let count: i64 = database
|
||||
.query_row("SELECT COUNT(*) FROM values_for_test", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
drop(writer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_scanner_connection_is_query_only() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let path = root.join("state.db");
|
||||
let connection = rusqlite::Connection::open(&path).unwrap();
|
||||
connection
|
||||
.execute_batch("CREATE TABLE values_for_test (value INTEGER);")
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
let approved = ApprovedRoot::new(&root).unwrap();
|
||||
let database = open_sqlite_transaction(&approved, &path).unwrap();
|
||||
let query_only: i64 = database
|
||||
.query_row("PRAGMA query_only", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(query_only, 1);
|
||||
assert!(
|
||||
database
|
||||
.execute("INSERT INTO values_for_test VALUES (1)", [])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_open_verifies_stable_file_identity() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = root.path().join("first");
|
||||
let second = root.path().join("second");
|
||||
std::fs::write(&first, "first").unwrap();
|
||||
std::fs::write(&second, "second").unwrap();
|
||||
let approved = ApprovedRoot::new(root.path()).unwrap();
|
||||
let opened = approved.open_regular_file(&first).unwrap();
|
||||
let first_again = windows::open_regular_path(&first).unwrap();
|
||||
let second = windows::open_regular_path(&second).unwrap();
|
||||
assert!(windows::same_file_for_test(&first_again, &opened.file));
|
||||
assert!(!windows::same_file_for_test(&second, &opened.file));
|
||||
assert!(opened.path.starts_with(approved.path()));
|
||||
assert!(windows::final_path_matches_for_test(
|
||||
&opened.path,
|
||||
&opened.file
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_directory_scans_fail_closed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let child = root.path().join("child");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
let approved = ApprovedRoot::new(root.path()).unwrap();
|
||||
assert!(approved.subroot(&child).is_none());
|
||||
assert!(!approved.for_each_entry(|_| panic!("enumerated on Windows")));
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use std::ffi::{CStr, OsStr, OsString};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::Path;
|
||||
|
||||
use super::DirectoryVisit;
|
||||
|
||||
pub(super) fn open_directory_path(path: &Path) -> Option<File> {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
|
||||
let mut options = OpenOptions::new();
|
||||
options
|
||||
.read(true)
|
||||
.custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
options.open(path).ok()
|
||||
}
|
||||
|
||||
pub(super) fn open_directory_relative(directory: &File, path: &Path) -> Option<File> {
|
||||
let mut current = directory.try_clone().ok()?;
|
||||
for component in path.components() {
|
||||
let std::path::Component::Normal(name) = component else {
|
||||
continue;
|
||||
};
|
||||
current = openat_component(
|
||||
¤t,
|
||||
name,
|
||||
libc::O_RDONLY
|
||||
| libc::O_DIRECTORY
|
||||
| libc::O_CLOEXEC
|
||||
| libc::O_NOFOLLOW
|
||||
| libc::O_NONBLOCK,
|
||||
)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
pub(super) fn open_regular_relative(directory: &File, path: &Path) -> Option<File> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new(""));
|
||||
let directory = open_directory_relative(directory, parent)?;
|
||||
openat_component(
|
||||
&directory,
|
||||
path.file_name()?,
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
|
||||
)
|
||||
}
|
||||
|
||||
fn openat_component(directory: &File, name: &OsStr, flags: i32) -> Option<File> {
|
||||
use std::os::fd::{AsRawFd as _, FromRawFd as _};
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let name = std::ffi::CString::new(name.as_bytes()).ok()?;
|
||||
// SAFETY: the directory fd and NUL-terminated child name are valid; no
|
||||
// creation mode argument is required because the flags never create.
|
||||
let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
// SAFETY: a nonnegative `openat` result transfers one owned fd.
|
||||
(fd >= 0).then(|| unsafe { File::from_raw_fd(fd) })
|
||||
}
|
||||
|
||||
struct DirectoryStream(*mut libc::DIR);
|
||||
|
||||
impl DirectoryStream {
|
||||
fn open(directory: &File) -> Option<Self> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
|
||||
let dot = c".";
|
||||
// SAFETY: opening "." relative to a live directory fd returns an
|
||||
// independent directory description for `fdopendir` to own.
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
dot.as_ptr(),
|
||||
libc::O_RDONLY
|
||||
| libc::O_DIRECTORY
|
||||
| libc::O_CLOEXEC
|
||||
| libc::O_NOFOLLOW
|
||||
| libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `fd` is an owned directory fd and ownership passes to DIR.
|
||||
let stream = unsafe { libc::fdopendir(fd) };
|
||||
if stream.is_null() {
|
||||
// SAFETY: ownership did not transfer when `fdopendir` failed.
|
||||
unsafe {
|
||||
libc::close(fd);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Some(Self(stream))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Result<Option<OsString>, ()> {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
loop {
|
||||
set_errno(0);
|
||||
// SAFETY: the stream remains owned and live until close/drop.
|
||||
let entry = unsafe { libc::readdir(self.0) };
|
||||
if entry.is_null() {
|
||||
return if errno() == 0 { Ok(None) } else { Err(()) };
|
||||
}
|
||||
// SAFETY: POSIX guarantees a NUL-terminated d_name for this entry.
|
||||
let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
|
||||
if name != b"." && name != b".." {
|
||||
return Ok(Some(OsString::from_vec(name.to_vec())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close(mut self) -> bool {
|
||||
let stream = std::mem::replace(&mut self.0, std::ptr::null_mut());
|
||||
// SAFETY: this is the one explicit close for the owned DIR stream.
|
||||
unsafe { libc::closedir(stream) == 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DirectoryStream {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
// SAFETY: Drop owns the stream only when explicit close did not.
|
||||
unsafe {
|
||||
libc::closedir(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn visit_directory_names(directory: &File, mut visit: impl FnMut(OsString)) -> bool {
|
||||
let Some(mut stream) = DirectoryStream::open(directory) else {
|
||||
return false;
|
||||
};
|
||||
let complete = loop {
|
||||
match stream.next() {
|
||||
Ok(Some(name)) => visit(name),
|
||||
Ok(None) => break true,
|
||||
Err(()) => break false,
|
||||
}
|
||||
};
|
||||
stream.close() && complete
|
||||
}
|
||||
|
||||
pub(super) fn visit_directory_names_bounded(
|
||||
directory: &File,
|
||||
max_entries: usize,
|
||||
mut visit: impl FnMut(OsString),
|
||||
) -> DirectoryVisit {
|
||||
let Some(mut stream) = DirectoryStream::open(directory) else {
|
||||
return DirectoryVisit {
|
||||
visited: 0,
|
||||
complete: false,
|
||||
};
|
||||
};
|
||||
let mut visited = 0;
|
||||
let complete = loop {
|
||||
if visited == max_entries {
|
||||
break matches!(stream.next(), Ok(None));
|
||||
}
|
||||
match stream.next() {
|
||||
Ok(Some(name)) => {
|
||||
visited += 1;
|
||||
visit(name);
|
||||
}
|
||||
Ok(None) => break true,
|
||||
Err(()) => break false,
|
||||
}
|
||||
};
|
||||
DirectoryVisit {
|
||||
visited,
|
||||
complete: stream.close() && complete,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "dragonfly"))]
|
||||
fn errno() -> i32 {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe { *libc::__errno_location() }
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "dragonfly"))]
|
||||
fn set_errno(value: i32) {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe {
|
||||
*libc::__errno_location() = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
|
||||
fn errno() -> i32 {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe { *libc::__error() }
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
|
||||
fn set_errno(value: i32) {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe {
|
||||
*libc::__error() = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn errno() -> i32 {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe { *libc::__errno() }
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn set_errno(value: i32) {
|
||||
// SAFETY: libc exposes one thread-local errno cell for the current thread.
|
||||
unsafe {
|
||||
*libc::__errno() = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "android",
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "dragonfly",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
)))]
|
||||
fn errno() -> i32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "android",
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "dragonfly",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
)))]
|
||||
fn set_errno(_value: i32) {}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn open_directory_path(path: &Path) -> Option<File> {
|
||||
use std::os::windows::fs::OpenOptionsExt as _;
|
||||
|
||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
let mut options = OpenOptions::new();
|
||||
options
|
||||
.read(true)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
options.open(path).ok()
|
||||
}
|
||||
|
||||
pub(super) fn open_regular_path(path: &Path) -> Option<File> {
|
||||
use std::os::windows::fs::OpenOptionsExt as _;
|
||||
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
let mut options = OpenOptions::new();
|
||||
options
|
||||
.read(true)
|
||||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
options.open(path).ok()
|
||||
}
|
||||
|
||||
pub(super) fn directory_path_matches(path: &Path, expected: &File) -> bool {
|
||||
let Some(opened) = open_directory_path(path) else {
|
||||
return false;
|
||||
};
|
||||
same_open_file(expected, &opened) && final_handle_path_matches(path, expected)
|
||||
}
|
||||
|
||||
pub(super) fn canonical_file_matches(path: &Path, expected: &File) -> bool {
|
||||
let Ok(canonical) = dunce::canonicalize(path) else {
|
||||
return false;
|
||||
};
|
||||
if canonical != path {
|
||||
return false;
|
||||
}
|
||||
let Some(opened) = open_regular_path(path) else {
|
||||
return false;
|
||||
};
|
||||
same_open_file(expected, &opened) && final_handle_path_matches(path, expected)
|
||||
}
|
||||
|
||||
pub(super) fn same_open_file(expected: &File, opened: &File) -> bool {
|
||||
file_identity(expected)
|
||||
.is_some_and(|expected| file_identity(opened).is_some_and(|opened| opened == expected))
|
||||
}
|
||||
|
||||
pub(super) fn final_handle_path_matches(path: &Path, file: &File) -> bool {
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
|
||||
final_path_from_raw_handle(file.as_raw_handle().cast()).is_some_and(|handle_path| {
|
||||
dunce::canonicalize(path).is_ok_and(|path| path == dunce::simplified(&handle_path))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct FileIdentity {
|
||||
volume_serial_number: u32,
|
||||
file_index: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(dead_code)]
|
||||
struct FileTime {
|
||||
low: u32,
|
||||
high: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(dead_code)]
|
||||
struct ByHandleFileInformation {
|
||||
file_attributes: u32,
|
||||
creation_time: FileTime,
|
||||
last_access_time: FileTime,
|
||||
last_write_time: FileTime,
|
||||
volume_serial_number: u32,
|
||||
file_size_high: u32,
|
||||
file_size_low: u32,
|
||||
number_of_links: u32,
|
||||
file_index_high: u32,
|
||||
file_index_low: u32,
|
||||
}
|
||||
|
||||
fn file_identity(file: &File) -> Option<FileIdentity> {
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
|
||||
raw_handle_identity(file.as_raw_handle().cast())
|
||||
}
|
||||
|
||||
fn raw_handle_identity(handle: *mut std::ffi::c_void) -> Option<FileIdentity> {
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
#[link_name = "GetFileInformationByHandle"]
|
||||
fn get_file_information_by_handle(
|
||||
file: *mut std::ffi::c_void,
|
||||
information: *mut ByHandleFileInformation,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
let mut information = std::mem::MaybeUninit::uninit();
|
||||
// SAFETY: the borrowed handle is live and the output has the exact
|
||||
// BY_HANDLE_FILE_INFORMATION layout.
|
||||
let result = unsafe { get_file_information_by_handle(handle, information.as_mut_ptr()) };
|
||||
if result == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: a nonzero result initializes the entire output structure.
|
||||
let information = unsafe { information.assume_init() };
|
||||
Some(FileIdentity {
|
||||
volume_serial_number: information.volume_serial_number,
|
||||
file_index: (u64::from(information.file_index_high) << 32)
|
||||
| u64::from(information.file_index_low),
|
||||
})
|
||||
}
|
||||
|
||||
fn final_path_from_raw_handle(handle: *mut std::ffi::c_void) -> Option<PathBuf> {
|
||||
use std::os::windows::ffi::OsStringExt as _;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
#[link_name = "GetFinalPathNameByHandleW"]
|
||||
fn get_final_path_name_by_handle(
|
||||
file: *mut std::ffi::c_void,
|
||||
path: *mut u16,
|
||||
path_len: u32,
|
||||
flags: u32,
|
||||
) -> u32;
|
||||
}
|
||||
|
||||
// SAFETY: a null output with length zero is the documented size query.
|
||||
let needed = unsafe { get_final_path_name_by_handle(handle, std::ptr::null_mut(), 0, 0) };
|
||||
if needed == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0_u16; needed as usize + 1];
|
||||
// SAFETY: `buffer` has the advertised writable UTF-16 capacity.
|
||||
let written = unsafe {
|
||||
get_final_path_name_by_handle(
|
||||
handle,
|
||||
buffer.as_mut_ptr(),
|
||||
u32::try_from(buffer.len()).unwrap_or(u32::MAX),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if written == 0 || written as usize >= buffer.len() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(OsString::from_wide(
|
||||
&buffer[..written as usize],
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn same_file_for_test(expected: &File, opened: &File) -> bool {
|
||||
same_open_file(expected, opened)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn final_path_matches_for_test(path: &Path, file: &File) -> bool {
|
||||
final_handle_path_matches(path, file)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
|
||||
MAX_SESSIONS_PER_TOOL, RecentCandidate, RecentProbe, approved_root_for_recent,
|
||||
finish_tool_scan, is_within, normalize_title, retain_top_k_by,
|
||||
};
|
||||
|
||||
mod projects;
|
||||
|
||||
const READ_CHUNK: usize = 64 * 1024;
|
||||
const MAX_HEAD: usize = 4 * 1024 * 1024;
|
||||
const MAX_CONTENT_READS: usize = 128;
|
||||
const MAX_RECENT_CONTENT_READS: usize = 16;
|
||||
const MAX_RECENT_DIRECTORY_ENTRIES: usize = 64;
|
||||
|
||||
struct Candidate {
|
||||
root: ApprovedRoot,
|
||||
path: PathBuf,
|
||||
session_id: String,
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
pub(super) fn scan(cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
|
||||
let Some(config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan_in_config_dir(&config_dir, cwd, now)
|
||||
}
|
||||
|
||||
pub(super) fn most_recent(
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let Some(config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
|
||||
else {
|
||||
return RecentProbe::Complete(None);
|
||||
};
|
||||
most_recent_in_config_dir(&config_dir, cwd, now, within)
|
||||
}
|
||||
|
||||
fn scan_in_config_dir(
|
||||
config_dir: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
) -> Vec<ForeignSessionSummary> {
|
||||
let Some(root) = ApprovedRoot::new(config_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let project_dirs = projects::scoped_project_dirs(root.path(), cwd);
|
||||
scan_project_dirs(&root, &project_dirs, cwd, now)
|
||||
}
|
||||
|
||||
fn most_recent_in_config_dir(
|
||||
config_dir: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let root = match approved_root_for_recent(config_dir) {
|
||||
Ok(Some(root)) => root,
|
||||
Ok(None) => return RecentProbe::Complete(None),
|
||||
Err(()) => return RecentProbe::Incomplete,
|
||||
};
|
||||
let Some(project_dir) = projects::project_dir_path(root.path(), cwd) else {
|
||||
return RecentProbe::Complete(None);
|
||||
};
|
||||
match std::fs::symlink_metadata(&project_dir) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return RecentProbe::Complete(None);
|
||||
}
|
||||
Err(_) => return RecentProbe::Incomplete,
|
||||
Ok(metadata) if project_directory_is_safe(&metadata) => {}
|
||||
Ok(_) => return RecentProbe::Incomplete,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let collected = root
|
||||
.subroot(&project_dir)
|
||||
.and_then(|project_root| collect_recent_candidates(&project_root, now, within));
|
||||
#[cfg(windows)]
|
||||
let collected = collect_recent_candidates_windows(&root, &project_dir, now, within);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let collected = None;
|
||||
|
||||
let Some((candidates, truncated)) = collected else {
|
||||
return RecentProbe::Incomplete;
|
||||
};
|
||||
finish_recent_candidates(candidates, truncated, cwd)
|
||||
}
|
||||
|
||||
fn finish_recent_candidates(
|
||||
candidates: Vec<Candidate>,
|
||||
truncated: bool,
|
||||
cwd: &Path,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let candidate = candidates.into_iter().find_map(|candidate| {
|
||||
qualify_candidate(&candidate, cwd)?;
|
||||
Some(RecentCandidate {
|
||||
tool: ForeignSessionTool::Claude,
|
||||
source: ForeignSessionSource::ClaudeCode,
|
||||
native_id: candidate.session_id,
|
||||
updated_at: candidate.modified,
|
||||
})
|
||||
});
|
||||
if candidate.is_none() && truncated {
|
||||
RecentProbe::Incomplete
|
||||
} else {
|
||||
RecentProbe::Complete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn project_directory_is_safe(metadata: &std::fs::Metadata) -> bool {
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt as _;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_recent_candidates(
|
||||
project_root: &ApprovedRoot,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> Option<(Vec<Candidate>, bool)> {
|
||||
let mut candidates = Vec::with_capacity(MAX_RECENT_CONTENT_READS);
|
||||
let mut qualifying = 0;
|
||||
let outcome = project_root.for_each_entry_bounded(MAX_RECENT_DIRECTORY_ENTRIES, |name| {
|
||||
let path = project_root.join(&name);
|
||||
let Some(session_id) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_suffix(".jsonl"))
|
||||
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((path, metadata)) = project_root.resolve_regular_file(&path) else {
|
||||
return;
|
||||
};
|
||||
if metadata.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return;
|
||||
};
|
||||
if is_within(modified, now, within) {
|
||||
qualifying += 1;
|
||||
retain_top_k_by(
|
||||
&mut candidates,
|
||||
Candidate {
|
||||
root: project_root.clone(),
|
||||
path,
|
||||
session_id: session_id.to_owned(),
|
||||
modified,
|
||||
size: metadata.len(),
|
||||
},
|
||||
MAX_RECENT_CONTENT_READS,
|
||||
candidate_order,
|
||||
);
|
||||
}
|
||||
});
|
||||
outcome
|
||||
.complete
|
||||
.then_some((candidates, qualifying > MAX_RECENT_CONTENT_READS))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn collect_recent_candidates_windows(
|
||||
config_root: &ApprovedRoot,
|
||||
project_dir: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> Option<(Vec<Candidate>, bool)> {
|
||||
let canonical_project = dunce::canonicalize(project_dir).ok()?;
|
||||
if canonical_project.as_path() != project_dir
|
||||
|| !canonical_project.starts_with(config_root.path())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let project_root = ApprovedRoot::new(&canonical_project)?;
|
||||
if project_root.path() != canonical_project.as_path() {
|
||||
return None;
|
||||
}
|
||||
let mut entries = std::fs::read_dir(&canonical_project).ok()?;
|
||||
let mut candidates = Vec::with_capacity(MAX_RECENT_CONTENT_READS);
|
||||
let mut qualifying = 0;
|
||||
for _ in 0..MAX_RECENT_DIRECTORY_ENTRIES {
|
||||
let Some(entry) = entries.next() else {
|
||||
return Some((candidates, qualifying > MAX_RECENT_CONTENT_READS));
|
||||
};
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
let Some(session_id) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_suffix(".jsonl"))
|
||||
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let opened = project_root.open_regular_file(&path)?;
|
||||
if opened.metadata.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
let modified = opened.metadata.modified().ok()?;
|
||||
if is_within(modified, now, within) {
|
||||
qualifying += 1;
|
||||
retain_top_k_by(
|
||||
&mut candidates,
|
||||
Candidate {
|
||||
root: project_root.clone(),
|
||||
path: opened.path,
|
||||
session_id: session_id.to_owned(),
|
||||
modified,
|
||||
size: opened.metadata.len(),
|
||||
},
|
||||
MAX_RECENT_CONTENT_READS,
|
||||
candidate_order,
|
||||
);
|
||||
}
|
||||
}
|
||||
match entries.next() {
|
||||
None => Some((candidates, qualifying > MAX_RECENT_CONTENT_READS)),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_project_dirs(
|
||||
root: &ApprovedRoot,
|
||||
project_dirs: &[PathBuf],
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
) -> Vec<ForeignSessionSummary> {
|
||||
let candidates =
|
||||
collect_candidates(root, project_dirs, now, MAX_SESSION_AGE, MAX_CONTENT_READS);
|
||||
let mut accepted_ids = HashSet::new();
|
||||
let mut sessions = Vec::new();
|
||||
for candidate in candidates {
|
||||
if accepted_ids.contains(&candidate.session_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(session) = read_candidate(candidate, cwd) else {
|
||||
continue;
|
||||
};
|
||||
accepted_ids.insert(session.native_id.clone());
|
||||
sessions.push(session);
|
||||
if sessions.len() == MAX_SESSIONS_PER_TOOL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
finish_tool_scan(sessions)
|
||||
}
|
||||
|
||||
fn collect_candidates(
|
||||
root: &ApprovedRoot,
|
||||
project_dirs: &[PathBuf],
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
limit: usize,
|
||||
) -> Vec<Candidate> {
|
||||
let mut candidates = Vec::with_capacity(limit);
|
||||
for project_dir in project_dirs.iter().take(projects::MAX_PROJECT_DIRS) {
|
||||
let Some(project_root) = root.subroot(project_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut project_candidates = Vec::with_capacity(limit);
|
||||
// Enumerate every direct entry in these already-scoped directories so
|
||||
// filesystem order cannot decide which sessions receive the read budget.
|
||||
let complete = project_root.for_each_entry(|name| {
|
||||
let path = project_root.join(&name);
|
||||
let Some(session_id) = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.strip_suffix(".jsonl"))
|
||||
.filter(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((path, metadata)) = project_root.resolve_regular_file(&path) else {
|
||||
return;
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return;
|
||||
};
|
||||
if !is_within(modified, now, within) {
|
||||
return;
|
||||
}
|
||||
retain_top_k_by(
|
||||
&mut project_candidates,
|
||||
Candidate {
|
||||
root: project_root.clone(),
|
||||
path,
|
||||
session_id: session_id.to_owned(),
|
||||
modified,
|
||||
size: metadata.len(),
|
||||
},
|
||||
limit,
|
||||
candidate_order,
|
||||
);
|
||||
});
|
||||
if !complete {
|
||||
continue;
|
||||
}
|
||||
for candidate in project_candidates {
|
||||
retain_top_k_by(&mut candidates, candidate, limit, candidate_order);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| a.session_id.cmp(&b.session_id))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
}
|
||||
|
||||
fn read_candidate(candidate: Candidate, requested_cwd: &Path) -> Option<ForeignSessionSummary> {
|
||||
let (head, stored_cwd) = qualify_candidate(&candidate, requested_cwd)?;
|
||||
let tail = read_tail(&candidate.root, &candidate.path, candidate.size)?;
|
||||
let first_prompt = first_prompt(&head);
|
||||
let title = [
|
||||
last_string(&tail, "customTitle").or_else(|| last_string(&head, "customTitle")),
|
||||
last_string(&tail, "aiTitle").or_else(|| last_string(&head, "aiTitle")),
|
||||
last_string(&tail, "lastPrompt").or_else(|| last_string(&head, "lastPrompt")),
|
||||
last_string(&tail, "summary").or_else(|| last_string(&head, "summary")),
|
||||
first_prompt,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| normalize_title(&value))?;
|
||||
let branch = last_string(&tail, "gitBranch")
|
||||
.or_else(|| last_string(&head, "gitBranch"))
|
||||
.and_then(|value| normalize_title(&value));
|
||||
|
||||
Some(ForeignSessionSummary {
|
||||
tool: ForeignSessionTool::Claude,
|
||||
source: ForeignSessionSource::ClaudeCode,
|
||||
native_id: candidate.session_id,
|
||||
title,
|
||||
cwd: PathBuf::from(stored_cwd),
|
||||
updated_at: candidate.modified,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn qualify_candidate(candidate: &Candidate, requested_cwd: &Path) -> Option<(String, String)> {
|
||||
let (head, stored_cwd) = read_head_for_cwd(&candidate.root, &candidate.path, candidate.size)?;
|
||||
let first_line = head.lines().next().unwrap_or(&head);
|
||||
if first_line.contains("\"isSidechain\":true") || first_line.contains("\"isSidechain\": true") {
|
||||
return None;
|
||||
}
|
||||
let stored_cwd = stored_cwd?;
|
||||
if Path::new(&stored_cwd) != requested_cwd {
|
||||
return None;
|
||||
}
|
||||
Some((head, stored_cwd))
|
||||
}
|
||||
|
||||
fn read_head_for_cwd(
|
||||
root: &ApprovedRoot,
|
||||
path: &Path,
|
||||
size: u64,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let max = usize::try_from(size.min(MAX_HEAD as u64)).ok()?;
|
||||
let mut limit = READ_CHUNK.min(max);
|
||||
loop {
|
||||
let head = read_prefix(root, path, limit)?;
|
||||
let cwd = first_string(&head, "cwd");
|
||||
if cwd.is_some() || limit >= max {
|
||||
return Some((head, cwd));
|
||||
}
|
||||
limit = limit.saturating_mul(4).min(max);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_prefix(root: &ApprovedRoot, path: &Path, limit: usize) -> Option<String> {
|
||||
let file = root.open_regular_file(path)?.file;
|
||||
let mut bytes = Vec::with_capacity(limit);
|
||||
file.take(limit as u64).read_to_end(&mut bytes).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn read_tail(root: &ApprovedRoot, path: &Path, size: u64) -> Option<String> {
|
||||
let len = size.min(READ_CHUNK as u64);
|
||||
let mut file = root.open_regular_file(path)?.file;
|
||||
file.seek(SeekFrom::Start(size.saturating_sub(len))).ok()?;
|
||||
let mut bytes = Vec::with_capacity(len as usize);
|
||||
file.take(len).read_to_end(&mut bytes).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn first_string(text: &str, key: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| string_field(line, key))
|
||||
}
|
||||
|
||||
fn last_string(text: &str, key: &str) -> Option<String> {
|
||||
text.lines().rev().find_map(|line| string_field(line, key))
|
||||
}
|
||||
|
||||
fn string_field(line: &str, key: &str) -> Option<String> {
|
||||
let value: Value = serde_json::from_str(line).ok()?;
|
||||
value.get(key)?.as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
fn first_prompt(head: &str) -> Option<String> {
|
||||
let mut command_fallback = None;
|
||||
for line in head.lines() {
|
||||
if line.contains("\"tool_result\"") {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if entry.get("type").and_then(Value::as_str) != Some("user")
|
||||
|| entry.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||
|| entry.get("isCompactSummary").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(content) = entry.pointer("/message/content") else {
|
||||
continue;
|
||||
};
|
||||
let texts = match content {
|
||||
Value::String(text) => vec![text.as_str()],
|
||||
Value::Array(blocks) => blocks
|
||||
.iter()
|
||||
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for text in texts {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(command) = between(&normalized, "<command-name>", "</command-name>") {
|
||||
command_fallback.get_or_insert_with(|| command.to_owned());
|
||||
continue;
|
||||
}
|
||||
if let Some(command) = between(&normalized, "<bash-input>", "</bash-input>") {
|
||||
return normalize_title(&format!("! {}", command.trim()));
|
||||
}
|
||||
if is_generated_prompt(&normalized) {
|
||||
continue;
|
||||
}
|
||||
return normalize_title(&normalized);
|
||||
}
|
||||
}
|
||||
command_fallback.and_then(|value| normalize_title(&value))
|
||||
}
|
||||
|
||||
fn between<'a>(value: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
let start = value.find(start)? + start.len();
|
||||
let end = value[start..].find(end)? + start;
|
||||
Some(&value[start..end])
|
||||
}
|
||||
|
||||
fn is_generated_prompt(value: &str) -> bool {
|
||||
if value.starts_with("[Request interrupted by user") {
|
||||
return true;
|
||||
}
|
||||
let value = value.trim_start();
|
||||
value
|
||||
.strip_prefix('<')
|
||||
.and_then(|rest| rest.chars().next())
|
||||
.is_some_and(|first| first.is_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests;
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests;
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const MAX_PROJECT_DIRS: usize = 16;
|
||||
pub(super) const MAX_SANITIZED_LENGTH: usize = 200;
|
||||
|
||||
pub(super) fn scoped_project_dirs(config_dir: &Path, cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![cwd.to_path_buf()];
|
||||
if let Ok(repository) = git2::Repository::discover(cwd) {
|
||||
if let Some(workdir) = repository.workdir() {
|
||||
paths.push(dunce::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()));
|
||||
if repository.path() != repository.commondir()
|
||||
&& let Some(main_workdir) = repository.commondir().parent()
|
||||
{
|
||||
paths.push(
|
||||
dunce::canonicalize(main_workdir)
|
||||
.unwrap_or_else(|_| main_workdir.to_path_buf()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Ok(worktrees) = repository.worktrees() {
|
||||
for name in worktrees.iter().flatten().flatten().take(MAX_PROJECT_DIRS) {
|
||||
if let Ok(worktree) = repository.find_worktree(name) {
|
||||
let path = worktree.path();
|
||||
paths.push(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|path| seen.insert(path.clone()))
|
||||
.take(MAX_PROJECT_DIRS)
|
||||
.filter_map(|path| project_dir_for_path(config_dir, &path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn project_dir_for_path(config_dir: &Path, path: &Path) -> Option<PathBuf> {
|
||||
let project_dir = project_dir_path(config_dir, path)?;
|
||||
let metadata = std::fs::symlink_metadata(&project_dir).ok()?;
|
||||
(metadata.is_dir() && !metadata.file_type().is_symlink()).then_some(project_dir)
|
||||
}
|
||||
|
||||
pub(super) fn project_dir_path(config_dir: &Path, path: &Path) -> Option<PathBuf> {
|
||||
let sanitized = path
|
||||
.to_string_lossy()
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect::<String>();
|
||||
if sanitized.len() > MAX_SANITIZED_LENGTH {
|
||||
return None;
|
||||
}
|
||||
Some(config_dir.join("projects").join(sanitized))
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use filetime::FileTime;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::foreign_sessions::{MAX_SESSION_AGE, canonical_tempdir};
|
||||
|
||||
fn set_mtime(path: &Path, time: SystemTime) {
|
||||
filetime::set_file_mtime(path, FileTime::from_system_time(time)).unwrap();
|
||||
}
|
||||
|
||||
fn write_session(
|
||||
project: &Path,
|
||||
id: uuid::Uuid,
|
||||
lines: &[serde_json::Value],
|
||||
modified: SystemTime,
|
||||
) {
|
||||
fs::create_dir_all(project).unwrap();
|
||||
let path = project.join(format!("{id}.jsonl"));
|
||||
let contents = lines
|
||||
.iter()
|
||||
.map(serde_json::Value::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(&path, format!("{contents}\n")).unwrap();
|
||||
set_mtime(&path, modified);
|
||||
}
|
||||
|
||||
fn user(cwd: &Path, prompt: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "user",
|
||||
"cwd": cwd,
|
||||
"message": {"role": "user", "content": prompt}
|
||||
})
|
||||
}
|
||||
|
||||
fn scoped_project(root: &Path, cwd: &Path) -> std::path::PathBuf {
|
||||
let name = cwd
|
||||
.to_string_lossy()
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect::<String>();
|
||||
let path = root.join("projects").join(name);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_skips_sidechains_and_wrong_cwds_before_winner() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let project = scoped_project(root.path(), &cwd);
|
||||
let now = UNIX_EPOCH + Duration::from_secs(3_000_000);
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(10),
|
||||
&[json!({
|
||||
"type": "user",
|
||||
"cwd": cwd,
|
||||
"isSidechain": true,
|
||||
"message": {"content": "sidechain"}
|
||||
})],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(11),
|
||||
&[user(Path::new("/other"), "wrong cwd")],
|
||||
now - Duration::from_secs(1),
|
||||
);
|
||||
let winner = uuid::Uuid::from_u128(12);
|
||||
write_session(
|
||||
&project,
|
||||
winner,
|
||||
&[user(&cwd, "winner")],
|
||||
now - Duration::from_secs(2),
|
||||
);
|
||||
|
||||
let found =
|
||||
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)).unwrap();
|
||||
assert_eq!(found.native_id, winner.to_string());
|
||||
assert_eq!(found.source, ForeignSessionSource::ClaudeCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_bounds_content_reads() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let project = scoped_project(root.path(), &cwd);
|
||||
let now = UNIX_EPOCH + Duration::from_secs(3_100_000);
|
||||
for i in 0..MAX_RECENT_CONTENT_READS {
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(100 + i as u128),
|
||||
&[user(Path::new("/other"), "wrong cwd")],
|
||||
now - Duration::from_secs(i as u64),
|
||||
);
|
||||
}
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(999),
|
||||
&[user(&cwd, "outside read budget")],
|
||||
now - Duration::from_secs(MAX_RECENT_CONTENT_READS as u64),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_fails_closed_at_directory_entry_cap() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let project = scoped_project(root.path(), &cwd);
|
||||
let now = UNIX_EPOCH + Duration::from_secs(3_200_000);
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(1_500),
|
||||
&[user(&cwd, "must not win after incomplete discovery")],
|
||||
now,
|
||||
);
|
||||
for index in 0..MAX_RECENT_DIRECTORY_ENTRIES {
|
||||
fs::write(project.join(format!("junk-{index:03}")), "").unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(root.path(), &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_bad_rows_and_uses_newest_duplicate_with_title_precedence() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(4_000_000);
|
||||
let projects = root.join("projects");
|
||||
let duplicate = uuid::Uuid::from_u128(1);
|
||||
|
||||
write_session(
|
||||
&projects.join("old"),
|
||||
duplicate,
|
||||
&[
|
||||
user(&cwd, "first prompt"),
|
||||
json!({"type": "custom-title", "customTitle": "old title"}),
|
||||
],
|
||||
now - Duration::from_secs(20),
|
||||
);
|
||||
write_session(
|
||||
&projects.join("new"),
|
||||
duplicate,
|
||||
&[
|
||||
user(&cwd, "first prompt"),
|
||||
json!({"summary": "summary"}),
|
||||
json!({"lastPrompt": "last prompt"}),
|
||||
json!({"aiTitle": "ai title"}),
|
||||
json!({"customTitle": "custom title", "gitBranch": "feature"}),
|
||||
],
|
||||
now - Duration::from_secs(10),
|
||||
);
|
||||
write_session(
|
||||
&projects.join("unreadable-newest"),
|
||||
duplicate,
|
||||
&[user(Path::new("/other"), "newest wrong cwd")],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&projects.join("sidechain"),
|
||||
uuid::Uuid::from_u128(2),
|
||||
&[json!({
|
||||
"type": "user",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"isSidechain": true,
|
||||
"message": {"content": "hidden"}
|
||||
})],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&projects.join("no-title"),
|
||||
uuid::Uuid::from_u128(3),
|
||||
&[json!({"type": "system", "cwd": cwd.display().to_string()})],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&projects.join("wrong-cwd"),
|
||||
uuid::Uuid::from_u128(4),
|
||||
&[user(Path::new("/other"), "wrong")],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&projects.join("stale"),
|
||||
uuid::Uuid::from_u128(5),
|
||||
&[user(&cwd, "stale")],
|
||||
now - MAX_SESSION_AGE - Duration::from_secs(1),
|
||||
);
|
||||
let zero_dir = projects.join("zero");
|
||||
fs::create_dir_all(&zero_dir).unwrap();
|
||||
fs::write(
|
||||
zero_dir.join(format!("{}.jsonl", uuid::Uuid::from_u128(6))),
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(projects.join("nested").join("subagents")).unwrap();
|
||||
fs::write(
|
||||
projects
|
||||
.join("nested")
|
||||
.join("subagents")
|
||||
.join(format!("{}.jsonl", uuid::Uuid::from_u128(7))),
|
||||
user(&cwd, "nested").to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project_dirs = fs::read_dir(&projects)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
|
||||
.map(|entry| entry.path())
|
||||
.collect::<Vec<_>>();
|
||||
let approved_root = ApprovedRoot::new(&root).unwrap();
|
||||
let sessions = scan_project_dirs(&approved_root, &project_dirs, &cwd, now);
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].native_id, duplicate.to_string());
|
||||
assert_eq!(sessions[0].title, "custom title");
|
||||
assert_eq!(sessions[0].branch.as_deref(), Some("feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_results_and_orders_ties_by_id() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(5_000_000);
|
||||
let project = scoped_project(root.path(), &cwd);
|
||||
for i in 1..=55_u128 {
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(i),
|
||||
&[user(&cwd, &format!("prompt {i}"))],
|
||||
now - Duration::from_secs((i / 2) as u64),
|
||||
);
|
||||
}
|
||||
|
||||
let sessions = scan_in_config_dir(root.path(), &cwd, now);
|
||||
assert_eq!(sessions.len(), MAX_SESSIONS_PER_TOOL);
|
||||
assert!(
|
||||
sessions
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].updated_at > pair[1].updated_at
|
||||
|| (pair[0].updated_at == pair[1].updated_at
|
||||
&& pair[0].native_id < pair[1].native_id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_meta_and_tool_noise_for_first_prompt() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(6_000_000);
|
||||
write_session(
|
||||
&scoped_project(root.path(), &cwd),
|
||||
uuid::Uuid::from_u128(100),
|
||||
&[
|
||||
json!({
|
||||
"type": "user",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"isMeta": true,
|
||||
"message": {"content": "meta"}
|
||||
}),
|
||||
json!({
|
||||
"type": "user",
|
||||
"message": {"content": [{"type": "tool_result", "content": "noise"}]}
|
||||
}),
|
||||
json!({
|
||||
"type": "user",
|
||||
"message": {"content": "<command-name>review</command-name>"}
|
||||
}),
|
||||
json!({
|
||||
"type": "user",
|
||||
"message": {"content": "real prompt"}
|
||||
}),
|
||||
],
|
||||
now,
|
||||
);
|
||||
|
||||
let sessions = scan_in_config_dir(root.path(), &cwd, now);
|
||||
assert_eq!(sessions[0].title, "real prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_scan_keeps_newest_over_budget_and_rejects_symlinks() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(7_000_000);
|
||||
let project = scoped_project(root.path(), &cwd);
|
||||
for i in 0..520_u128 {
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(1_000 + i),
|
||||
&[user(Path::new("/other"), "wrong cwd")],
|
||||
now - Duration::from_secs(i as u64 + 1),
|
||||
);
|
||||
}
|
||||
let valid = uuid::Uuid::from_u128(2_000);
|
||||
write_session(
|
||||
&project,
|
||||
valid,
|
||||
&[user(&cwd, "valid after invalid rows")],
|
||||
now,
|
||||
);
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(2_003),
|
||||
&[user(&cwd, "arbitrary future mtime")],
|
||||
now + Duration::from_secs(24 * 60 * 60),
|
||||
);
|
||||
write_session(
|
||||
&root.path().join("projects").join("unrelated"),
|
||||
uuid::Uuid::from_u128(2_001),
|
||||
&[user(&cwd, "must not scan unrelated project")],
|
||||
now,
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = root.path().join("outside.jsonl");
|
||||
fs::write(&outside, user(&cwd, "symlink escape").to_string()).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
outside,
|
||||
project.join(format!("{}.jsonl", uuid::Uuid::from_u128(2_002))),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let sessions = scan_in_config_dir(root.path(), &cwd, now);
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].native_id, valid.to_string());
|
||||
assert_eq!(sessions[0].title, "valid after invalid rows");
|
||||
|
||||
let long = PathBuf::from(format!(
|
||||
"/{}",
|
||||
"a".repeat(projects::MAX_SANITIZED_LENGTH + 1)
|
||||
));
|
||||
assert_eq!(projects::project_dir_for_path(root.path(), &long), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_project_parent_symlink_escape() {
|
||||
let config = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let cwd = config.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(7_500_000);
|
||||
let project = scoped_project(outside.path(), &cwd);
|
||||
write_session(
|
||||
&project,
|
||||
uuid::Uuid::from_u128(2_100),
|
||||
&[user(&cwd, "outside parent")],
|
||||
now,
|
||||
);
|
||||
std::os::unix::fs::symlink(
|
||||
outside.path().join("projects"),
|
||||
config.path().join("projects"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(scan_in_config_dir(config.path(), &cwd, now).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_worktree_scope_includes_main_and_siblings() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let main = root.path().join("main");
|
||||
fs::create_dir_all(&main).unwrap();
|
||||
let repository = git2::Repository::init(&main).unwrap();
|
||||
let signature = git2::Signature::now("test", "test@example.com").unwrap();
|
||||
let tree = {
|
||||
let mut index = repository.index().unwrap();
|
||||
let oid = index.write_tree().unwrap();
|
||||
repository.find_tree(oid).unwrap()
|
||||
};
|
||||
repository
|
||||
.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
|
||||
.unwrap();
|
||||
drop(tree);
|
||||
let linked = root.path().join("linked");
|
||||
let sibling = root.path().join("sibling");
|
||||
repository.worktree("linked", &linked, None).unwrap();
|
||||
repository.worktree("sibling", &sibling, None).unwrap();
|
||||
let main_project = scoped_project(root.path(), &dunce::canonicalize(&main).unwrap());
|
||||
let linked_project = scoped_project(root.path(), &dunce::canonicalize(&linked).unwrap());
|
||||
let sibling_project = scoped_project(root.path(), &dunce::canonicalize(&sibling).unwrap());
|
||||
|
||||
let project_dirs =
|
||||
projects::scoped_project_dirs(root.path(), &dunce::canonicalize(&linked).unwrap());
|
||||
assert!(project_dirs.contains(&main_project));
|
||||
assert!(project_dirs.contains(&linked_project));
|
||||
assert!(project_dirs.contains(&sibling_project));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use filetime::FileTime;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Fixture {
|
||||
_root: TempDir,
|
||||
config: PathBuf,
|
||||
cwd: PathBuf,
|
||||
project: PathBuf,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = TempDir::new().unwrap();
|
||||
let config = root.path().join("config");
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&config).unwrap();
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let config = dunce::canonicalize(config).unwrap();
|
||||
let cwd = dunce::canonicalize(cwd).unwrap();
|
||||
let project = projects::project_dir_path(&config, &cwd).unwrap();
|
||||
Fixture {
|
||||
_root: root,
|
||||
config,
|
||||
cwd,
|
||||
project,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_session(project: &Path, cwd: &Path, id: uuid::Uuid, modified: SystemTime) {
|
||||
fs::create_dir_all(project).unwrap();
|
||||
let path = project.join(format!("{id}.jsonl"));
|
||||
fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{}\n",
|
||||
json!({
|
||||
"type": "user",
|
||||
"cwd": cwd,
|
||||
"message": {"role": "user", "content": "recent work"},
|
||||
})
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
filetime::set_file_mtime(&path, FileTime::from_system_time(modified)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_finds_valid_session() {
|
||||
let fixture = fixture();
|
||||
let now = SystemTime::now();
|
||||
let id = uuid::Uuid::from_u128(1);
|
||||
write_session(&fixture.project, &fixture.cwd, id, now);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(&fixture.config, &fixture.cwd, now, Duration::from_secs(600),)
|
||||
.unwrap()
|
||||
.native_id,
|
||||
id.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_missing_project_is_complete_empty() {
|
||||
let fixture = fixture();
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(
|
||||
&fixture.config,
|
||||
&fixture.cwd,
|
||||
SystemTime::now(),
|
||||
Duration::from_secs(600),
|
||||
),
|
||||
RecentProbe::Complete(None),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_directory_limit_is_incomplete() {
|
||||
let fixture = fixture();
|
||||
let now = SystemTime::now();
|
||||
write_session(
|
||||
&fixture.project,
|
||||
&fixture.cwd,
|
||||
uuid::Uuid::from_u128(2),
|
||||
now,
|
||||
);
|
||||
for index in 0..MAX_RECENT_DIRECTORY_ENTRIES {
|
||||
fs::write(fixture.project.join(format!("junk-{index:03}")), "").unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(&fixture.config, &fixture.cwd, now, Duration::from_secs(600),),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_probe_rejects_reparse_project_directory_when_supported() {
|
||||
let fixture = fixture();
|
||||
let outside = fixture._root.path().join("outside-project");
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
fs::create_dir_all(fixture.project.parent().unwrap()).unwrap();
|
||||
if std::os::windows::fs::symlink_dir(&outside, &fixture.project).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_config_dir(
|
||||
&fixture.config,
|
||||
&fixture.cwd,
|
||||
SystemTime::now(),
|
||||
Duration::from_secs(600),
|
||||
),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use super::super::{
|
||||
ApprovedRoot, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
|
||||
MAX_SESSIONS_PER_TOOL, RecentCandidate, is_within, millis_bounds, normalize_title,
|
||||
open_sqlite_transaction, system_time_from_millis,
|
||||
};
|
||||
use super::{existing_rollout_path, source_from_persisted, title};
|
||||
|
||||
const MAX_DB_CANDIDATES: usize = 200;
|
||||
pub(super) const MAX_RECENT_DB_CANDIDATES: usize = 8;
|
||||
const MIN_EPOCH_MILLIS: i64 = 1_577_836_800_000;
|
||||
const MAX_ID_BYTES: usize = 64;
|
||||
const MAX_PATH_BYTES: usize = 16 * 1024;
|
||||
const MAX_TEXT_BYTES: usize = 64 * 1024;
|
||||
const MAX_BRANCH_BYTES: usize = 4 * 1024;
|
||||
|
||||
struct DbCandidate {
|
||||
id: String,
|
||||
rollout_path: String,
|
||||
updated_at: i64,
|
||||
source: String,
|
||||
stored_cwd: String,
|
||||
title: String,
|
||||
first_user_message: String,
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) enum RecentDatabaseResult {
|
||||
Unusable,
|
||||
Incomplete,
|
||||
Usable(Option<RecentCandidate>),
|
||||
}
|
||||
|
||||
pub(super) fn scan_database(
|
||||
root: &ApprovedRoot,
|
||||
db_path: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
) -> Option<Vec<ForeignSessionSummary>> {
|
||||
let bounds = millis_bounds(now, MAX_SESSION_AGE)?;
|
||||
let cwd_string = cwd.to_str()?.to_owned();
|
||||
if cwd_string.len() > MAX_PATH_BYTES {
|
||||
return None;
|
||||
}
|
||||
let database = open_sqlite_transaction(root, db_path)?;
|
||||
let columns = table_columns(&database, "threads")?;
|
||||
let sql = scan_sql(&columns)?;
|
||||
let rows = query_candidates(&database, &sql, &cwd_string, bounds)?;
|
||||
let mut sessions = Vec::new();
|
||||
for row in rows {
|
||||
let Some(candidate) =
|
||||
qualify_candidate(root, cwd, now, MAX_SESSION_AGE, &row, source_from_persisted)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(title) = title(&row.title, &row.first_user_message) else {
|
||||
continue;
|
||||
};
|
||||
sessions.push(ForeignSessionSummary {
|
||||
tool: ForeignSessionTool::Codex,
|
||||
source: candidate.source,
|
||||
native_id: candidate.native_id,
|
||||
title,
|
||||
cwd: Path::new(&row.stored_cwd).to_path_buf(),
|
||||
updated_at: candidate.updated_at,
|
||||
branch: row.branch.as_deref().and_then(normalize_title),
|
||||
});
|
||||
if sessions.len() == MAX_SESSIONS_PER_TOOL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(sessions)
|
||||
}
|
||||
|
||||
pub(super) fn most_recent_database(
|
||||
root: &ApprovedRoot,
|
||||
db_path: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentDatabaseResult {
|
||||
let Some(bounds) = millis_bounds(now, within) else {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
};
|
||||
let Some(cwd_string) = cwd.to_str().map(str::to_owned) else {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
};
|
||||
if cwd_string.len() > MAX_PATH_BYTES {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
}
|
||||
let Some(database) = open_sqlite_transaction(root, db_path) else {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
};
|
||||
let Some(columns) = table_columns(&database, "threads") else {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
};
|
||||
let Some(sql) = recent_scan_sql(&columns) else {
|
||||
return RecentDatabaseResult::Unusable;
|
||||
};
|
||||
let Ok(mut rows) = query_recent_candidates(&database, &sql, &cwd_string, bounds) else {
|
||||
return RecentDatabaseResult::Incomplete;
|
||||
};
|
||||
let truncated = rows.len() > MAX_RECENT_DB_CANDIDATES;
|
||||
rows.truncate(MAX_RECENT_DB_CANDIDATES);
|
||||
let candidate = rows
|
||||
.into_iter()
|
||||
.find_map(|row| qualify_candidate(root, cwd, now, within, &row, super::source_from_str));
|
||||
if candidate.is_none() && truncated {
|
||||
RecentDatabaseResult::Incomplete
|
||||
} else {
|
||||
RecentDatabaseResult::Usable(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn query_candidates(
|
||||
database: &Connection,
|
||||
sql: &str,
|
||||
cwd: &str,
|
||||
(oldest_millis, newest_millis): (i64, i64),
|
||||
) -> Option<Vec<DbCandidate>> {
|
||||
let mut statement = database.prepare(sql).ok()?;
|
||||
let rows = statement
|
||||
.query_map(
|
||||
params![cwd, oldest_millis, newest_millis],
|
||||
decode_candidate_row,
|
||||
)
|
||||
.ok()?;
|
||||
Some(rows.flatten().collect())
|
||||
}
|
||||
|
||||
fn query_recent_candidates(
|
||||
database: &Connection,
|
||||
sql: &str,
|
||||
cwd: &str,
|
||||
(oldest_millis, newest_millis): (i64, i64),
|
||||
) -> Result<Vec<DbCandidate>, ()> {
|
||||
let mut statement = database.prepare(sql).map_err(|_| ())?;
|
||||
let rows = statement
|
||||
.query_map(
|
||||
params![cwd, oldest_millis, newest_millis],
|
||||
decode_candidate_row,
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| ())
|
||||
}
|
||||
|
||||
fn decode_candidate_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<DbCandidate> {
|
||||
Ok(DbCandidate {
|
||||
id: row.get(0)?,
|
||||
rollout_path: row.get(1)?,
|
||||
updated_at: row.get(2)?,
|
||||
source: row.get(3)?,
|
||||
stored_cwd: row.get(4)?,
|
||||
title: row.get(5)?,
|
||||
first_user_message: row.get(6)?,
|
||||
branch: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn qualify_candidate(
|
||||
root: &ApprovedRoot,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
row: &DbCandidate,
|
||||
parse_source: impl Fn(&str) -> Option<super::super::ForeignSessionSource>,
|
||||
) -> Option<RecentCandidate> {
|
||||
if uuid::Uuid::try_parse(&row.id).is_err() || Path::new(&row.stored_cwd) != cwd {
|
||||
return None;
|
||||
}
|
||||
let source = parse_source(&row.source)?;
|
||||
let updated_at = normalize_updated_at(row.updated_at)?;
|
||||
if !is_within(updated_at, now, within)
|
||||
|| existing_rollout_path(root, &row.rollout_path, &row.id).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(RecentCandidate {
|
||||
tool: ForeignSessionTool::Codex,
|
||||
source,
|
||||
native_id: row.id.clone(),
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn normalize_updated_at(value: i64) -> Option<SystemTime> {
|
||||
let millis = if value < MIN_EPOCH_MILLIS {
|
||||
value.saturating_mul(1_000)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
system_time_from_millis(millis)
|
||||
}
|
||||
|
||||
pub(super) fn scan_sql(columns: &HashSet<String>) -> Option<String> {
|
||||
scan_sql_with_limit(
|
||||
columns,
|
||||
MAX_DB_CANDIDATES,
|
||||
"('cli', 'vscode', '{\"custom\":\"atlas\"}', '{\"custom\":\"chatgpt\"}')",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn recent_scan_sql(columns: &HashSet<String>) -> Option<String> {
|
||||
scan_sql_with_limit(columns, MAX_RECENT_DB_CANDIDATES + 1, "('cli', 'vscode')")
|
||||
}
|
||||
|
||||
fn scan_sql_with_limit(
|
||||
columns: &HashSet<String>,
|
||||
limit: usize,
|
||||
allowed_sources: &str,
|
||||
) -> Option<String> {
|
||||
for required in ["id", "rollout_path", "source", "cwd", "archived"] {
|
||||
if !columns.contains(required) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let updated_column = if columns.contains("updated_at_ms") {
|
||||
"updated_at_ms"
|
||||
} else if columns.contains("updated_at") {
|
||||
"updated_at"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let title_column = if columns.contains("title") {
|
||||
"title"
|
||||
} else {
|
||||
"''"
|
||||
};
|
||||
let first_user_message = if columns.contains("first_user_message") {
|
||||
"first_user_message"
|
||||
} else {
|
||||
"''"
|
||||
};
|
||||
let git_branch = if columns.contains("git_branch") {
|
||||
"git_branch"
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let title_projection = format!(
|
||||
"CASE WHEN typeof({title_column}) = 'text' \
|
||||
AND octet_length({title_column}) <= {MAX_TEXT_BYTES} \
|
||||
THEN {title_column} ELSE '' END"
|
||||
);
|
||||
let first_projection = format!(
|
||||
"CASE WHEN typeof({first_user_message}) = 'text' \
|
||||
AND octet_length({first_user_message}) <= {MAX_TEXT_BYTES} \
|
||||
THEN {first_user_message} ELSE '' END"
|
||||
);
|
||||
let branch_projection = format!(
|
||||
"CASE WHEN typeof({git_branch}) = 'text' \
|
||||
AND octet_length({git_branch}) <= {MAX_BRANCH_BYTES} \
|
||||
THEN {git_branch} ELSE NULL END"
|
||||
);
|
||||
Some(format!(
|
||||
"SELECT id, rollout_path, {updated_column}, source, cwd, \
|
||||
{title_projection}, {first_projection}, {branch_projection} \
|
||||
FROM threads \
|
||||
WHERE typeof(id) = 'text' \
|
||||
AND typeof(rollout_path) = 'text' \
|
||||
AND typeof({updated_column}) = 'integer' \
|
||||
AND typeof(archived) = 'integer' \
|
||||
AND archived = 0 AND cwd = ?1 \
|
||||
AND source IN {allowed_sources} \
|
||||
AND octet_length(id) <= {MAX_ID_BYTES} \
|
||||
AND octet_length(rollout_path) <= {MAX_PATH_BYTES} \
|
||||
AND CASE \
|
||||
WHEN {updated_column} < {MIN_EPOCH_MILLIS} THEN {updated_column} * 1000 \
|
||||
ELSE {updated_column} \
|
||||
END BETWEEN ?2 AND ?3 \
|
||||
ORDER BY CASE \
|
||||
WHEN {updated_column} < {MIN_EPOCH_MILLIS} THEN {updated_column} * 1000 \
|
||||
ELSE {updated_column} \
|
||||
END DESC, id ASC LIMIT {limit}"
|
||||
))
|
||||
}
|
||||
|
||||
fn table_columns(connection: &Connection, table: &str) -> Option<HashSet<String>> {
|
||||
let mut statement = connection
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.ok()?;
|
||||
let rows = statement
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.ok()?;
|
||||
let columns = rows.flatten().collect::<HashSet<_>>();
|
||||
(!columns.is_empty()).then_some(columns)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use chrono::{DateTime, Datelike, Days, Local, TimeDelta, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::{
|
||||
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, ForeignSessionTool, MAX_SESSION_AGE,
|
||||
MAX_SESSIONS_PER_TOOL, RecentCandidate, RecentProbe, is_within, normalize_title,
|
||||
retain_top_k_by,
|
||||
};
|
||||
use super::{rollout_id, source_from_value, title};
|
||||
|
||||
const DAYS_IN_WINDOW: usize = 31;
|
||||
const MAX_DATE_DIRS: usize = 32;
|
||||
const MAX_METADATA_READS: usize = 128;
|
||||
const MAX_RECENT_METADATA_READS: usize = 16;
|
||||
pub(super) const MAX_RECENT_DIRECTORY_ENTRIES: usize = 64;
|
||||
const MAX_HEAD_RECORDS: usize = 10;
|
||||
pub(super) const MAX_HEAD_BYTES: usize = 64 * 1024;
|
||||
// Bound compressed work separately from the decoded head and decoder window.
|
||||
pub(super) const MAX_COMPRESSED_HEAD_BYTES: usize = 256 * 1024;
|
||||
pub(super) const MAX_ZSTD_WINDOW_LOG: u32 = 23;
|
||||
|
||||
struct Candidate {
|
||||
root: ApprovedRoot,
|
||||
path: PathBuf,
|
||||
id: String,
|
||||
modified: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadMetadata {
|
||||
id: Option<uuid::Uuid>,
|
||||
cwd: Option<String>,
|
||||
source: Option<ForeignSessionSource>,
|
||||
branch: Option<String>,
|
||||
first_user_message: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn scan_rollouts(
|
||||
codex_root: &ApprovedRoot,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
) -> Vec<ForeignSessionSummary> {
|
||||
let Some(root) = codex_root.subroot(Path::new("sessions")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let candidates = collect_candidates(&root, now, MAX_SESSION_AGE, MAX_METADATA_READS);
|
||||
let mut accepted_ids = HashSet::new();
|
||||
let mut sessions = Vec::new();
|
||||
for candidate in candidates {
|
||||
if accepted_ids.contains(&candidate.id) {
|
||||
continue;
|
||||
}
|
||||
let Some(session) = read_candidate(candidate, cwd) else {
|
||||
continue;
|
||||
};
|
||||
accepted_ids.insert(session.native_id.clone());
|
||||
sessions.push(session);
|
||||
if sessions.len() == MAX_SESSIONS_PER_TOOL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
pub(super) fn most_recent_rollout(
|
||||
codex_root: &ApprovedRoot,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let sessions_path = codex_root.join("sessions");
|
||||
match std::fs::symlink_metadata(&sessions_path) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return RecentProbe::Complete(None);
|
||||
}
|
||||
Err(_) => return RecentProbe::Incomplete,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let Some(root) = codex_root.subroot(&sessions_path) else {
|
||||
return RecentProbe::Incomplete;
|
||||
};
|
||||
let Some((candidates, truncated)) = collect_recent_candidates(&root, now, within) else {
|
||||
return RecentProbe::Incomplete;
|
||||
};
|
||||
let candidate = candidates.into_iter().find_map(|candidate| {
|
||||
let (_, source, _) = qualify_candidate(&candidate, cwd)?;
|
||||
if !matches!(
|
||||
source,
|
||||
ForeignSessionSource::CodexCli | ForeignSessionSource::CodexVsCode
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(RecentCandidate {
|
||||
tool: ForeignSessionTool::Codex,
|
||||
source,
|
||||
native_id: candidate.id,
|
||||
updated_at: candidate.modified,
|
||||
})
|
||||
});
|
||||
if candidate.is_none() && truncated {
|
||||
RecentProbe::Incomplete
|
||||
} else {
|
||||
RecentProbe::Complete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_recent_candidates(
|
||||
root: &ApprovedRoot,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> Option<(Vec<Candidate>, bool)> {
|
||||
let mut candidates = Vec::with_capacity(MAX_RECENT_METADATA_READS);
|
||||
let mut remaining_entries = MAX_RECENT_DIRECTORY_ENTRIES;
|
||||
let mut qualifying = 0;
|
||||
let local_offset = DateTime::<Local>::from(now).offset().local_minus_utc();
|
||||
for date_dir in recent_date_dirs(root.path(), now, local_offset) {
|
||||
let Some(date_root) = root.subroot(&date_dir) else {
|
||||
continue;
|
||||
};
|
||||
let outcome = date_root.for_each_entry_bounded(remaining_entries, |name| {
|
||||
let path = date_root.join(&name);
|
||||
let Some(id) = rollout_id(&path) else {
|
||||
return;
|
||||
};
|
||||
let Some((path, metadata)) = date_root.resolve_regular_file(&path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return;
|
||||
};
|
||||
if is_within(modified, now, within) {
|
||||
qualifying += 1;
|
||||
retain_top_k_by(
|
||||
&mut candidates,
|
||||
Candidate {
|
||||
root: date_root.clone(),
|
||||
path,
|
||||
id,
|
||||
modified,
|
||||
},
|
||||
MAX_RECENT_METADATA_READS,
|
||||
candidate_order,
|
||||
);
|
||||
}
|
||||
});
|
||||
remaining_entries = remaining_entries.saturating_sub(outcome.visited);
|
||||
if !outcome.complete {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((candidates, qualifying > MAX_RECENT_METADATA_READS))
|
||||
}
|
||||
|
||||
fn collect_candidates(
|
||||
root: &ApprovedRoot,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
limit: usize,
|
||||
) -> Vec<Candidate> {
|
||||
let mut candidates = Vec::with_capacity(limit);
|
||||
let local_offset = DateTime::<Local>::from(now).offset().local_minus_utc();
|
||||
for date_dir in recent_date_dirs(root.path(), now, local_offset) {
|
||||
let Some(date_root) = root.subroot(&date_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut date_candidates = Vec::with_capacity(limit);
|
||||
// Enumerate every direct rollout entry in the fixed date window so
|
||||
// filesystem order cannot decide which files receive the head-read budget.
|
||||
let complete = date_root.for_each_entry(|name| {
|
||||
let path = date_root.join(&name);
|
||||
let Some(id) = rollout_id(&path) else {
|
||||
return;
|
||||
};
|
||||
let Some((path, metadata)) = date_root.resolve_regular_file(&path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return;
|
||||
};
|
||||
if is_within(modified, now, within) {
|
||||
retain_top_k_by(
|
||||
&mut date_candidates,
|
||||
Candidate {
|
||||
root: date_root.clone(),
|
||||
path,
|
||||
id,
|
||||
modified,
|
||||
},
|
||||
limit,
|
||||
candidate_order,
|
||||
);
|
||||
}
|
||||
});
|
||||
if !complete {
|
||||
continue;
|
||||
}
|
||||
for candidate in date_candidates {
|
||||
retain_top_k_by(&mut candidates, candidate, limit, candidate_order);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
}
|
||||
|
||||
pub(super) fn recent_date_dirs(
|
||||
root: &Path,
|
||||
now: SystemTime,
|
||||
local_offset_seconds: i32,
|
||||
) -> Vec<PathBuf> {
|
||||
let utc = DateTime::<Utc>::from(now);
|
||||
let local = utc + TimeDelta::seconds(i64::from(local_offset_seconds));
|
||||
let mut dates = [utc.date_naive(), local.date_naive()]
|
||||
.into_iter()
|
||||
.flat_map(|today| {
|
||||
(0..DAYS_IN_WINDOW)
|
||||
.filter_map(move |days| today.checked_sub_days(Days::new(days as u64)))
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
dates.sort_by(|a, b| b.cmp(a));
|
||||
dates.truncate(MAX_DATE_DIRS);
|
||||
dates
|
||||
.into_iter()
|
||||
.map(|date| {
|
||||
root.join(format!("{:04}", date.year()))
|
||||
.join(format!("{:02}", date.month()))
|
||||
.join(format!("{:02}", date.day()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_candidate(candidate: Candidate, requested_cwd: &Path) -> Option<ForeignSessionSummary> {
|
||||
let (
|
||||
HeadMetadata {
|
||||
branch,
|
||||
first_user_message,
|
||||
..
|
||||
},
|
||||
source,
|
||||
stored_cwd,
|
||||
) = qualify_candidate(&candidate, requested_cwd)?;
|
||||
let title = title("", first_user_message.as_deref().unwrap_or(""))?;
|
||||
Some(ForeignSessionSummary {
|
||||
tool: ForeignSessionTool::Codex,
|
||||
source,
|
||||
native_id: candidate.id,
|
||||
title,
|
||||
cwd: PathBuf::from(stored_cwd),
|
||||
updated_at: candidate.modified,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn qualify_candidate(
|
||||
candidate: &Candidate,
|
||||
requested_cwd: &Path,
|
||||
) -> Option<(HeadMetadata, ForeignSessionSource, String)> {
|
||||
let metadata = read_head(&candidate.root, &candidate.path)?;
|
||||
if metadata.id? != uuid::Uuid::try_parse(&candidate.id).ok()? {
|
||||
return None;
|
||||
}
|
||||
let stored_cwd = metadata.cwd.clone()?;
|
||||
if Path::new(&stored_cwd) != requested_cwd {
|
||||
return None;
|
||||
}
|
||||
let source = metadata.source?;
|
||||
Some((metadata, source, stored_cwd))
|
||||
}
|
||||
|
||||
fn read_head(root: &ApprovedRoot, path: &Path) -> Option<HeadMetadata> {
|
||||
let file = root.open_regular_file(path)?.file;
|
||||
if path.extension().and_then(|extension| extension.to_str()) == Some("zst") {
|
||||
let compressed = file.take(MAX_COMPRESSED_HEAD_BYTES as u64);
|
||||
let mut decoder = zstd::Decoder::new(compressed).ok()?;
|
||||
decoder.window_log_max(MAX_ZSTD_WINDOW_LOG).ok()?;
|
||||
let decoder = decoder.single_frame();
|
||||
parse_head(BufReader::new(decoder.take(MAX_HEAD_BYTES as u64)))
|
||||
} else {
|
||||
parse_head(BufReader::new(file.take(MAX_HEAD_BYTES as u64)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_head(mut reader: impl BufRead) -> Option<HeadMetadata> {
|
||||
let mut line = String::new();
|
||||
let mut total = 0;
|
||||
let mut metadata = HeadMetadata::default();
|
||||
let mut saw_session_meta = false;
|
||||
for _ in 0..MAX_HEAD_RECORDS {
|
||||
line.clear();
|
||||
let read = reader.read_line(&mut line).ok()?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
total += read;
|
||||
if total > MAX_HEAD_BYTES {
|
||||
break;
|
||||
}
|
||||
let Ok(record) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let record_type = record.get("type").and_then(Value::as_str);
|
||||
let payload = record.get("payload").unwrap_or(&Value::Null);
|
||||
if record_type == Some("session_meta") && !saw_session_meta {
|
||||
saw_session_meta = true;
|
||||
metadata.id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| uuid::Uuid::try_parse(id).ok());
|
||||
metadata.cwd = payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
metadata.source = payload.get("source").and_then(source_from_value);
|
||||
metadata.branch = payload
|
||||
.pointer("/git/branch")
|
||||
.or_else(|| payload.get("git_branch"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_title);
|
||||
}
|
||||
if metadata.first_user_message.is_none() {
|
||||
metadata.first_user_message = user_message(payload).and_then(normalize_title);
|
||||
}
|
||||
}
|
||||
Some(metadata)
|
||||
}
|
||||
|
||||
fn user_message(payload: &Value) -> Option<&str> {
|
||||
if payload.get("type").and_then(Value::as_str) == Some("user_message") {
|
||||
return payload.get("message").and_then(Value::as_str);
|
||||
}
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message")
|
||||
|| payload.get("role").and_then(Value::as_str) != Some("user")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let text = payload
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find_map(|item| match item.get("type").and_then(Value::as_str) {
|
||||
Some("input_text" | "text") => item.get("text").and_then(Value::as_str),
|
||||
_ => None,
|
||||
})?;
|
||||
let trimmed = text.trim_start();
|
||||
(!trimmed.starts_with("<environment_context>") && !trimmed.starts_with("<user_instructions>"))
|
||||
.then_some(text)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
mod db;
|
||||
mod files;
|
||||
|
||||
use super::{
|
||||
ApprovedRoot, ForeignSessionSource, ForeignSessionSummary, RecentCandidate, RecentProbe,
|
||||
approved_root_for_recent, finish_tool_scan, normalize_title,
|
||||
};
|
||||
|
||||
// Codex is currently on single-digit state generations. Probe a generous,
|
||||
// deterministic supported range without enumerating unrelated CODEX_HOME files.
|
||||
const MAX_STATE_DB_GENERATION: u32 = 128;
|
||||
|
||||
pub(super) fn scan(cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
|
||||
let Some(codex_home) = std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan_in_home(&codex_home, cwd, now)
|
||||
}
|
||||
|
||||
pub(super) fn most_recent(
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let Some(codex_home) = std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
|
||||
else {
|
||||
return RecentProbe::Complete(None);
|
||||
};
|
||||
most_recent_in_home(&codex_home, cwd, now, within)
|
||||
}
|
||||
|
||||
fn scan_in_home(codex_home: &Path, cwd: &Path, now: SystemTime) -> Vec<ForeignSessionSummary> {
|
||||
let Some(root) = ApprovedRoot::new(codex_home) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sessions = state_databases(&root)
|
||||
.find_map(|path| {
|
||||
db::scan_database(&root, &path, cwd, now).filter(|sessions| !sessions.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| files::scan_rollouts(&root, cwd, now));
|
||||
finish_tool_scan(sessions)
|
||||
}
|
||||
|
||||
fn most_recent_in_home(
|
||||
codex_home: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
within: Duration,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
let root = match approved_root_for_recent(codex_home) {
|
||||
Ok(Some(root)) => root,
|
||||
Ok(None) => return RecentProbe::Complete(None),
|
||||
Err(()) => return RecentProbe::Incomplete,
|
||||
};
|
||||
let Some(path) = highest_named_state_database(&root) else {
|
||||
return files::most_recent_rollout(&root, cwd, now, within);
|
||||
};
|
||||
match db::most_recent_database(&root, &path, cwd, now, within) {
|
||||
db::RecentDatabaseResult::Usable(candidate) => RecentProbe::Complete(candidate),
|
||||
db::RecentDatabaseResult::Incomplete => RecentProbe::Incomplete,
|
||||
db::RecentDatabaseResult::Unusable => files::most_recent_rollout(&root, cwd, now, within),
|
||||
}
|
||||
}
|
||||
|
||||
fn highest_named_state_database(root: &ApprovedRoot) -> Option<PathBuf> {
|
||||
(0..=MAX_STATE_DB_GENERATION).rev().find_map(|generation| {
|
||||
let path = root.join(format!("state_{generation}.sqlite"));
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(_) => Some(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => Some(path),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn state_databases(root: &ApprovedRoot) -> impl Iterator<Item = PathBuf> + '_ {
|
||||
(0..=MAX_STATE_DB_GENERATION)
|
||||
.rev()
|
||||
.filter_map(move |generation| {
|
||||
let path = root.join(format!("state_{generation}.sqlite"));
|
||||
root.resolve_regular_file(&path).map(|(path, _)| path)
|
||||
})
|
||||
}
|
||||
|
||||
fn source_from_str(source: &str) -> Option<ForeignSessionSource> {
|
||||
match source {
|
||||
"cli" => Some(ForeignSessionSource::CodexCli),
|
||||
"vscode" => Some(ForeignSessionSource::CodexVsCode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_from_persisted(source: &str) -> Option<ForeignSessionSource> {
|
||||
source_from_str(source).or_else(|| {
|
||||
let value = serde_json::from_str::<Value>(source).ok()?;
|
||||
source_from_value(&value)
|
||||
})
|
||||
}
|
||||
|
||||
fn source_from_value(source: &Value) -> Option<ForeignSessionSource> {
|
||||
source
|
||||
.as_str()
|
||||
.and_then(source_from_str)
|
||||
.or_else(|| match source.get("custom")?.as_str()? {
|
||||
"atlas" => Some(ForeignSessionSource::CodexAtlas),
|
||||
"chatgpt" => Some(ForeignSessionSource::CodexChatGpt),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn existing_rollout_path(root: &ApprovedRoot, value: &str, expected_id: &str) -> Option<PathBuf> {
|
||||
let path = PathBuf::from(value);
|
||||
if path.components().any(|part| part == Component::ParentDir) {
|
||||
return None;
|
||||
}
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
let mut compressed = path.clone().into_os_string();
|
||||
compressed.push(".zst");
|
||||
let approved_prefixes = ["sessions", "archived_sessions"]
|
||||
.into_iter()
|
||||
.filter_map(|name| dunce::canonicalize(root.join(name)).ok())
|
||||
.filter(|path| path.starts_with(root.path()))
|
||||
.collect::<Vec<_>>();
|
||||
[path, PathBuf::from(compressed)]
|
||||
.into_iter()
|
||||
.filter_map(|candidate| root.open_regular_file(&candidate).map(|opened| opened.path))
|
||||
.find(|candidate| {
|
||||
rollout_id(candidate).as_deref() == Some(expected_id)
|
||||
&& approved_prefixes
|
||||
.iter()
|
||||
.any(|prefix| candidate.starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn rollout_id(path: &Path) -> Option<String> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let stem = name
|
||||
.strip_suffix(".jsonl.zst")
|
||||
.or_else(|| name.strip_suffix(".jsonl"))?;
|
||||
let value = stem.strip_prefix("rollout-")?;
|
||||
let id_start = value.len().checked_sub(36)?;
|
||||
if id_start == 0 || value.as_bytes().get(id_start - 1) != Some(&b'-') {
|
||||
return None;
|
||||
}
|
||||
let timestamp = &value[..id_start - 1];
|
||||
if timestamp.len() != 19 {
|
||||
return None;
|
||||
}
|
||||
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%S").ok()?;
|
||||
let id = &value[id_start..];
|
||||
uuid::Uuid::try_parse(id).ok()?;
|
||||
Some(id.to_owned())
|
||||
}
|
||||
|
||||
fn title(primary: &str, fallback: &str) -> Option<String> {
|
||||
normalize_title(primary).or_else(|| normalize_title(fallback))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixed_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_rollout_qualification_does_not_require_enumeration() {
|
||||
let (_tempdir, root) = crate::foreign_sessions::canonical_tempdir();
|
||||
let sessions = root.join("sessions/2027/01/15");
|
||||
std::fs::create_dir_all(&sessions).unwrap();
|
||||
let id = uuid::Uuid::from_u128(9_000);
|
||||
let rollout = sessions.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
|
||||
std::fs::write(&rollout, "").unwrap();
|
||||
let approved = ApprovedRoot::new(&root).unwrap();
|
||||
assert_eq!(
|
||||
existing_rollout_path(&approved, &rollout.to_string_lossy(), &id.to_string()),
|
||||
Some(dunce::canonicalize(&rollout).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,889 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codex_query_uses_cwd_index_and_length_metadata_opcode() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let db_path = root.path().join("state.sqlite");
|
||||
create_db(&db_path, &[]);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
let columns = [
|
||||
"id",
|
||||
"rollout_path",
|
||||
"updated_at_ms",
|
||||
"source",
|
||||
"cwd",
|
||||
"title",
|
||||
"first_user_message",
|
||||
"archived",
|
||||
"git_branch",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let sql = super::super::db::scan_sql(&columns).unwrap();
|
||||
let mut plan = connection
|
||||
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
|
||||
.unwrap();
|
||||
let details = plan
|
||||
.query_map(params!["/repo", 0_i64, i64::MAX], |row| {
|
||||
row.get::<_, String>(3)
|
||||
})
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(details.iter().any(|detail| {
|
||||
detail.contains("SEARCH threads USING INDEX threads_archived_cwd_updated")
|
||||
&& detail.contains("archived=? AND cwd=?")
|
||||
}));
|
||||
assert!(
|
||||
details
|
||||
.iter()
|
||||
.any(|detail| detail.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||
);
|
||||
|
||||
let mut bytecode = connection.prepare(&format!("EXPLAIN {sql}")).unwrap();
|
||||
let columns = bytecode
|
||||
.query_map(params!["/repo", 0_i64, i64::MAX], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
for column in [0_i64, 1, 5, 6, 8] {
|
||||
assert!(columns.iter().any(|(opcode, p2, p5)| {
|
||||
opcode == "Column" && *p2 == column && (*p5 & 0xc0) == 0xc0
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_database_probe_is_windowed_bounded_and_keeps_source_filters() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let rollout_dir = root.join("sessions/2027/01/15");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&rollout_dir).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let winner = uuid::Uuid::from_u128(90);
|
||||
let winner_rollout = rollout_path(&rollout_dir, winner);
|
||||
fs::write(&winner_rollout, "").unwrap();
|
||||
let missing = rollout_path(&rollout_dir, uuid::Uuid::from_u128(91));
|
||||
let db_path = root.join("state_9.sqlite");
|
||||
create_db(
|
||||
&db_path,
|
||||
&[
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(93),
|
||||
rollout_path: &winner_rollout,
|
||||
updated_at_ms: millis_from_system_time(now + Duration::from_secs(1)).unwrap(),
|
||||
source: r#"{"custom":"atlas"}"#,
|
||||
cwd: &cwd,
|
||||
title: "excluded custom source",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(92),
|
||||
rollout_path: &winner_rollout,
|
||||
updated_at_ms: millis_from_system_time(now).unwrap(),
|
||||
source: r#"{"subagent":"review"}"#,
|
||||
cwd: &cwd,
|
||||
title: "excluded",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(91),
|
||||
rollout_path: &missing,
|
||||
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "missing rollout",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: winner,
|
||||
rollout_path: &winner_rollout,
|
||||
updated_at_ms: millis_from_system_time(now - Duration::from_secs(2)).unwrap(),
|
||||
source: "vscode",
|
||||
cwd: &cwd,
|
||||
title: "",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let found = most_recent_in_home(&root, &cwd, now, Duration::from_secs(600)).unwrap();
|
||||
assert_eq!(found.native_id, winner.to_string());
|
||||
assert_eq!(found.source, ForeignSessionSource::CodexVsCode);
|
||||
|
||||
let columns = [
|
||||
"id",
|
||||
"rollout_path",
|
||||
"updated_at_ms",
|
||||
"source",
|
||||
"cwd",
|
||||
"title",
|
||||
"first_user_message",
|
||||
"archived",
|
||||
"git_branch",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let sql = super::super::db::recent_scan_sql(&columns).unwrap();
|
||||
assert!(sql.contains("source IN ('cli', 'vscode')"));
|
||||
assert!(!sql.contains("custom"));
|
||||
assert!(sql.contains(&format!(
|
||||
"LIMIT {}",
|
||||
super::super::db::MAX_RECENT_DB_CANDIDATES + 1
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_database_sentinel_marks_invalid_window_incomplete() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
let sessions = root.path().join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_010_000);
|
||||
let valid_id = uuid::Uuid::from_u128(190);
|
||||
let valid_rollout = rollout_path(&sessions, valid_id);
|
||||
fs::write(&valid_rollout, "").unwrap();
|
||||
let db_path = root.path().join("state_10.sqlite");
|
||||
create_db(&db_path, &[]);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
for index in 0..super::super::db::MAX_RECENT_DB_CANDIDATES {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'invalid', '', 0, NULL)",
|
||||
params![
|
||||
uuid::Uuid::from_u128(10_000 + index as u128).to_string(),
|
||||
sessions
|
||||
.join(format!("missing-{index}.jsonl"))
|
||||
.display()
|
||||
.to_string(),
|
||||
millis_from_system_time(now - Duration::from_secs(index as u64)).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Complete(None),
|
||||
);
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'ninth valid', '', 0, NULL)",
|
||||
params![
|
||||
valid_id.to_string(),
|
||||
valid_rollout.display().to_string(),
|
||||
millis_from_system_time(now - Duration::from_secs(20)).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_database_row_decode_error_is_incomplete() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_015_000);
|
||||
let older_id = uuid::Uuid::from_u128(191);
|
||||
let older_rollout = rollout_path(&sessions, older_id);
|
||||
fs::write(&older_rollout, "").unwrap();
|
||||
let db_path = root.join("state_10.sqlite");
|
||||
create_db(
|
||||
&db_path,
|
||||
&[DbRow {
|
||||
id: older_id,
|
||||
rollout_path: &older_rollout,
|
||||
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "older valid",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
let broken_id = uuid::Uuid::from_u128(192);
|
||||
let broken_rollout = rollout_path(&sessions, broken_id);
|
||||
fs::write(&broken_rollout, "").unwrap();
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, CAST(x'80' AS TEXT), '', 0, NULL)",
|
||||
params![
|
||||
broken_id.to_string(),
|
||||
broken_rollout.display().to_string(),
|
||||
millis_from_system_time(now).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_home(&root, &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
let full = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(full.len(), 1);
|
||||
assert_eq!(full[0].native_id, older_id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_database_tri_state_uses_only_current_generation() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_020_000);
|
||||
|
||||
let readable = TempDir::new().unwrap();
|
||||
let readable_cwd = readable.path().join("repo");
|
||||
fs::create_dir_all(&readable_cwd).unwrap();
|
||||
let older_id = uuid::Uuid::from_u128(200);
|
||||
let older_rollout =
|
||||
write_recent_rollout(readable.path(), &readable_cwd, now, older_id, json!("cli"));
|
||||
create_db(
|
||||
&readable.path().join("state_9.sqlite"),
|
||||
&[DbRow {
|
||||
id: older_id,
|
||||
rollout_path: &older_rollout,
|
||||
updated_at_ms: millis_from_system_time(now).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &readable_cwd,
|
||||
title: "obsolete generation",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
create_db(&readable.path().join("state_10.sqlite"), &[]);
|
||||
assert!(
|
||||
most_recent_in_home(
|
||||
readable.path(),
|
||||
&readable_cwd,
|
||||
now,
|
||||
Duration::from_secs(600)
|
||||
)
|
||||
.is_none(),
|
||||
"a usable empty current index must suppress obsolete DB and rollout fallback"
|
||||
);
|
||||
|
||||
let unreadable = TempDir::new().unwrap();
|
||||
let unreadable_cwd = unreadable.path().join("repo");
|
||||
fs::create_dir_all(&unreadable_cwd).unwrap();
|
||||
let fallback_id = uuid::Uuid::from_u128(201);
|
||||
write_recent_rollout(
|
||||
unreadable.path(),
|
||||
&unreadable_cwd,
|
||||
now,
|
||||
fallback_id,
|
||||
json!("vscode"),
|
||||
);
|
||||
fs::write(unreadable.path().join("state_10.sqlite"), "not sqlite").unwrap();
|
||||
assert_eq!(
|
||||
most_recent_in_home(
|
||||
unreadable.path(),
|
||||
&unreadable_cwd,
|
||||
now,
|
||||
Duration::from_secs(600)
|
||||
)
|
||||
.unwrap()
|
||||
.native_id,
|
||||
fallback_id.to_string()
|
||||
);
|
||||
|
||||
let absent = TempDir::new().unwrap();
|
||||
let absent_cwd = absent.path().join("repo");
|
||||
fs::create_dir_all(&absent_cwd).unwrap();
|
||||
let absent_id = uuid::Uuid::from_u128(202);
|
||||
write_recent_rollout(absent.path(), &absent_cwd, now, absent_id, json!("cli"));
|
||||
assert_eq!(
|
||||
most_recent_in_home(absent.path(), &absent_cwd, now, Duration::from_secs(600))
|
||||
.unwrap()
|
||||
.native_id,
|
||||
absent_id.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn recent_database_unsafe_highest_uses_fallback_not_older_database() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_025_000);
|
||||
let older_id = uuid::Uuid::from_u128(210);
|
||||
let older_rollout = write_recent_rollout(root.path(), &cwd, now, older_id, json!("cli"));
|
||||
touch(&older_rollout, now - Duration::from_secs(60 * 60));
|
||||
let older_db = root.path().join("state_9.sqlite");
|
||||
create_db(
|
||||
&older_db,
|
||||
&[DbRow {
|
||||
id: older_id,
|
||||
rollout_path: &older_rollout,
|
||||
updated_at_ms: millis_from_system_time(now).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "must not use older DB",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
std::os::unix::fs::symlink(&older_db, root.path().join("state_10.sqlite")).unwrap();
|
||||
|
||||
let fallback_id = uuid::Uuid::from_u128(211);
|
||||
write_recent_rollout(root.path(), &cwd, now, fallback_id, json!("vscode"));
|
||||
assert_eq!(
|
||||
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600))
|
||||
.unwrap()
|
||||
.native_id,
|
||||
fallback_id.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_database_filters_sources_paths_cwd_and_millis() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let updated = now - Duration::from_secs(2);
|
||||
let updated_ms = millis_from_system_time(updated).unwrap();
|
||||
let rollout_dir = root.join("sessions/2026/01/01");
|
||||
fs::create_dir_all(&rollout_dir).unwrap();
|
||||
let old_rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(1));
|
||||
fs::write(&old_rollout, "").unwrap();
|
||||
create_db(
|
||||
&root.join("state_5.sqlite"),
|
||||
&[DbRow {
|
||||
id: uuid::Uuid::from_u128(1),
|
||||
rollout_path: &old_rollout,
|
||||
updated_at_ms: updated_ms,
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "old generation",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
|
||||
let rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(2));
|
||||
fs::write(&rollout, "").unwrap();
|
||||
let compressed_plain = rollout_path(&rollout_dir, uuid::Uuid::from_u128(3));
|
||||
fs::write(format!("{}.zst", compressed_plain.display()), "").unwrap();
|
||||
let chatgpt_rollout = rollout_path(&rollout_dir, uuid::Uuid::from_u128(9));
|
||||
fs::write(&chatgpt_rollout, "").unwrap();
|
||||
let missing = rollout_path(&rollout_dir, uuid::Uuid::from_u128(5));
|
||||
let stale_ms = millis_from_system_time(
|
||||
now - super::super::super::MAX_SESSION_AGE - Duration::from_secs(1),
|
||||
)
|
||||
.unwrap();
|
||||
create_db(
|
||||
&root.join("state_9.sqlite"),
|
||||
&[
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(2),
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: updated_ms,
|
||||
source: "vscode",
|
||||
cwd: &cwd,
|
||||
title: "",
|
||||
first_user_message: "fallback title",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(3),
|
||||
rollout_path: &compressed_plain,
|
||||
updated_at_ms: updated_ms - 1,
|
||||
source: r#"{"custom":"atlas"}"#,
|
||||
cwd: &cwd,
|
||||
title: "compressed",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(9),
|
||||
rollout_path: &chatgpt_rollout,
|
||||
updated_at_ms: updated_ms - 2,
|
||||
source: r#"{"custom":"chatgpt"}"#,
|
||||
cwd: &cwd,
|
||||
title: "chatgpt",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(4),
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: updated_ms,
|
||||
source: r#"{"subagent":"review"}"#,
|
||||
cwd: &cwd,
|
||||
title: "subagent",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(5),
|
||||
rollout_path: &missing,
|
||||
updated_at_ms: updated_ms,
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "missing",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(6),
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: stale_ms,
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "stale",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(7),
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: updated_ms,
|
||||
source: "cli",
|
||||
cwd: Path::new("/other"),
|
||||
title: "wrong cwd",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
},
|
||||
DbRow {
|
||||
id: uuid::Uuid::from_u128(8),
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: updated_ms,
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "archived",
|
||||
first_user_message: "",
|
||||
archived: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let sessions = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(
|
||||
sessions
|
||||
.iter()
|
||||
.map(|session| session.title.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["fallback title", "compressed", "chatgpt"]
|
||||
);
|
||||
assert_eq!(sessions[0].updated_at, updated);
|
||||
assert_eq!(sessions[0].source, ForeignSessionSource::CodexVsCode);
|
||||
assert_eq!(sessions[1].source, ForeignSessionSource::CodexAtlas);
|
||||
assert_eq!(sessions[2].source, ForeignSessionSource::CodexChatGpt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_newer_database_uses_older_nonempty_generation() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let rollout_dir = root.join("sessions/2027/01/15");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&rollout_dir).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_050_000);
|
||||
let id = uuid::Uuid::from_u128(40);
|
||||
let rollout = rollout_path(&rollout_dir, id);
|
||||
fs::write(&rollout, "").unwrap();
|
||||
create_db(
|
||||
&root.join("state_9.sqlite"),
|
||||
&[DbRow {
|
||||
id,
|
||||
rollout_path: &rollout,
|
||||
updated_at_ms: millis_from_system_time(now).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "older nonempty generation",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
create_db(&root.join("state_10.sqlite"), &[]);
|
||||
|
||||
let sessions = scan_in_home(&root, &cwd, now);
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].native_id, id.to_string());
|
||||
assert_eq!(sessions[0].title, "older nonempty generation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_databases_fall_back_to_rollout_files() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_100_000);
|
||||
let day = session_day(root.path(), now);
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let id = uuid::Uuid::from_u128(41);
|
||||
let rollout = rollout_path(&day, id);
|
||||
let contents = [
|
||||
json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": id,
|
||||
"cwd": cwd.display().to_string(),
|
||||
"source": "cli"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": "rollout fallback"
|
||||
}
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|record| record.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
fs::write(&rollout, contents).unwrap();
|
||||
touch(&rollout, now);
|
||||
create_db(&root.path().join("state_10.sqlite"), &[]);
|
||||
|
||||
let sessions = scan_in_home(root.path(), &cwd, now);
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].native_id, id.to_string());
|
||||
assert_eq!(sessions[0].title, "rollout fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_window_qualifies_past_invalid_rows() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_200_000);
|
||||
let valid_id = uuid::Uuid::from_u128(500);
|
||||
let valid_path = rollout_path(&sessions, valid_id);
|
||||
fs::write(&valid_path, "").unwrap();
|
||||
let db_path = root.join("state_10.sqlite");
|
||||
create_db(
|
||||
&db_path,
|
||||
&[DbRow {
|
||||
id: valid_id,
|
||||
rollout_path: &valid_path,
|
||||
updated_at_ms: millis_from_system_time(now - Duration::from_secs(100)).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "valid after window",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
for i in 0..60_u128 {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, 'invalid', '', 0, NULL)",
|
||||
params![
|
||||
uuid::Uuid::from_u128(1_000 + i).to_string(),
|
||||
sessions
|
||||
.join(format!("missing-{i}.jsonl"))
|
||||
.display()
|
||||
.to_string(),
|
||||
millis_from_system_time(now - Duration::from_secs(i as u64)).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for i in 0..220_u128 {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, 'subagent', '', 0, NULL)",
|
||||
params![
|
||||
uuid::Uuid::from_u128(2_000 + i).to_string(),
|
||||
valid_path.display().to_string(),
|
||||
millis_from_system_time(now - Duration::from_secs(i as u64)).unwrap(),
|
||||
r#"{"subagent":"review"}"#,
|
||||
cwd.display().to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(connection);
|
||||
let found = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].title, "valid after window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_window_normalizes_units_and_filters_future_rows_before_limit() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_250_000);
|
||||
let valid_id = uuid::Uuid::from_u128(2_500);
|
||||
let valid_path = rollout_path(&sessions, valid_id);
|
||||
fs::write(&valid_path, "").unwrap();
|
||||
let db_path = root.join("state_10.sqlite");
|
||||
create_db(
|
||||
&db_path,
|
||||
&[DbRow {
|
||||
id: valid_id,
|
||||
rollout_path: &valid_path,
|
||||
updated_at_ms: i64::try_from(
|
||||
(now - Duration::from_secs(1))
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
)
|
||||
.unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "newer seconds row",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
for (prefix, offset) in [
|
||||
("stale", -(10 * 24 * 60 * 60_i64)),
|
||||
("future", 24 * 60 * 60),
|
||||
] {
|
||||
for i in 0..205_u128 {
|
||||
let timestamp = if offset < 0 {
|
||||
now - Duration::from_secs((-offset) as u64 + i as u64)
|
||||
} else {
|
||||
now + Duration::from_secs(offset as u64 + i as u64)
|
||||
};
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, ?5, '', 0, NULL)",
|
||||
params![
|
||||
uuid::Uuid::from_u128(3_000 + i + if offset > 0 { 1_000 } else { 0 })
|
||||
.to_string(),
|
||||
sessions
|
||||
.join(format!("missing-{prefix}-{i}.jsonl"))
|
||||
.display()
|
||||
.to_string(),
|
||||
millis_from_system_time(timestamp).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
prefix,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
drop(connection);
|
||||
let found = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].title, "newer seconds row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_required_sql_predicate_protects_the_candidate_window() {
|
||||
for case in 0..5_u128 {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_260_000 + case as u64);
|
||||
let valid_id = uuid::Uuid::from_u128(6_000 + case);
|
||||
let valid_path = rollout_path(&sessions, valid_id);
|
||||
fs::write(&valid_path, "").unwrap();
|
||||
let db_path = root.join("state_10.sqlite");
|
||||
create_db(
|
||||
&db_path,
|
||||
&[DbRow {
|
||||
id: valid_id,
|
||||
rollout_path: &valid_path,
|
||||
updated_at_ms: millis_from_system_time(now - Duration::from_secs(1)).unwrap(),
|
||||
source: "cli",
|
||||
cwd: &cwd,
|
||||
title: "valid required row",
|
||||
first_user_message: "",
|
||||
archived: false,
|
||||
}],
|
||||
);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
for i in 0..201_u128 {
|
||||
let id_text = uuid::Uuid::from_u128(7_000 + i).to_string();
|
||||
let mut id = SqlValue::Text(id_text.clone());
|
||||
let rollout_text = sessions
|
||||
.join(format!("missing-required-{case}-{i}.jsonl"))
|
||||
.display()
|
||||
.to_string();
|
||||
let mut rollout = SqlValue::Text(rollout_text.clone());
|
||||
let mut updated = SqlValue::Integer(millis_from_system_time(now).unwrap());
|
||||
let source = SqlValue::Text("cli".to_owned());
|
||||
let stored_cwd = SqlValue::Text(cwd.display().to_string());
|
||||
match case {
|
||||
0 => id = SqlValue::Blob(id_text.into_bytes()),
|
||||
1 => id = SqlValue::Text(oversized_text(64, i % 2 == 1)),
|
||||
2 => rollout = SqlValue::Blob(rollout_text.into_bytes()),
|
||||
3 => rollout = SqlValue::Text(oversized_text(16 * 1024, i % 2 == 1)),
|
||||
_ => updated = SqlValue::Real(millis_from_system_time(now).unwrap() as f64 + 0.5),
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, 'invalid', '', 0, NULL)",
|
||||
params![id, rollout, updated, source, stored_cwd],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(connection);
|
||||
let found = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(found.len(), 1, "required predicate case {case}");
|
||||
assert_eq!(found[0].native_id, valid_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_codex_metadata_degrades_without_dropping_rows() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let cwd = root.join("repo");
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_270_000);
|
||||
let db_path = root.join("state_10.sqlite");
|
||||
create_db(&db_path, &[]);
|
||||
let connection = Connection::open(&db_path).unwrap();
|
||||
let expected = [
|
||||
"fallback oversized title",
|
||||
"fallback wrong title type",
|
||||
"title with oversized fallback",
|
||||
"title with wrong fallback type",
|
||||
"title with oversized branch",
|
||||
"title with wrong branch type",
|
||||
];
|
||||
for (index, expected_title) in expected.iter().enumerate() {
|
||||
let id = uuid::Uuid::from_u128(8_000 + index as u128);
|
||||
let path = rollout_path(&sessions, id);
|
||||
fs::write(&path, "").unwrap();
|
||||
let mut title = SqlValue::Text((*expected_title).to_owned());
|
||||
let mut first = SqlValue::Text((*expected_title).to_owned());
|
||||
let mut branch = SqlValue::Text("main".to_owned());
|
||||
match index {
|
||||
0 => title = SqlValue::Text(oversized_text(64 * 1024, false)),
|
||||
1 => title = SqlValue::Blob(b"wrong title".to_vec()),
|
||||
2 => first = SqlValue::Text(oversized_text(64 * 1024, true)),
|
||||
3 => first = SqlValue::Blob(b"wrong fallback".to_vec()),
|
||||
4 => branch = SqlValue::Text(oversized_text(4 * 1024, false)),
|
||||
_ => branch = SqlValue::Blob(b"wrong branch".to_vec()),
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, 'cli', ?4, ?5, ?6, 0, ?7)",
|
||||
params![
|
||||
id.to_string(),
|
||||
path.display().to_string(),
|
||||
millis_from_system_time(now - Duration::from_secs(index as u64)).unwrap(),
|
||||
cwd.display().to_string(),
|
||||
title,
|
||||
first,
|
||||
branch,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(connection);
|
||||
let found = scan_in_home(&root, &cwd, now);
|
||||
assert_eq!(found.len(), expected.len());
|
||||
for (index, expected_title) in expected.iter().enumerate() {
|
||||
let id = uuid::Uuid::from_u128(8_000 + index as u128).to_string();
|
||||
let session = found
|
||||
.iter()
|
||||
.find(|session| session.native_id == id)
|
||||
.unwrap();
|
||||
assert_eq!(session.title, *expected_title);
|
||||
if index >= 4 {
|
||||
assert_eq!(session.branch, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_database_probes_have_a_supported_generation_ceiling() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
for i in 0..100 {
|
||||
fs::write(root.join(format!("unrelated-{i:03}")), "").unwrap();
|
||||
}
|
||||
fs::write(root.join("state_2.sqlite"), "").unwrap();
|
||||
let boundary = root.join(format!("state_{MAX_STATE_DB_GENERATION}.sqlite"));
|
||||
let beyond = root.join(format!("state_{}.sqlite", MAX_STATE_DB_GENERATION + 1));
|
||||
fs::write(&boundary, "").unwrap();
|
||||
fs::write(&beyond, "").unwrap();
|
||||
let approved_root = ApprovedRoot::new(&root).unwrap();
|
||||
assert_eq!(
|
||||
state_databases(&approved_root).collect::<Vec<_>>(),
|
||||
vec![boundary.clone(), root.join("state_2.sqlite")]
|
||||
);
|
||||
fs::remove_file(boundary).unwrap();
|
||||
assert_eq!(
|
||||
state_databases(&approved_root).collect::<Vec<_>>(),
|
||||
vec![root.join("state_2.sqlite")]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn state_database_probe_rejects_symlink_escape() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let codex_home = root.path().join("codex");
|
||||
fs::create_dir_all(&codex_home).unwrap();
|
||||
let outside = root.path().join("outside.sqlite");
|
||||
fs::write(&outside, "").unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
&outside,
|
||||
codex_home.join(format!("state_{MAX_STATE_DB_GENERATION}.sqlite")),
|
||||
)
|
||||
.unwrap();
|
||||
let approved_root = ApprovedRoot::new(&codex_home).unwrap();
|
||||
assert!(state_databases(&approved_root).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_seconds_and_millis_then_uses_shared_recency() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
let expected = now - Duration::from_secs(10);
|
||||
let seconds = 1_799_999_990_i64;
|
||||
let millis = millis_from_system_time(expected).unwrap();
|
||||
assert_eq!(
|
||||
super::super::db::normalize_updated_at(seconds),
|
||||
Some(expected)
|
||||
);
|
||||
assert_eq!(
|
||||
super::super::db::normalize_updated_at(millis),
|
||||
Some(expected)
|
||||
);
|
||||
let future = now + Duration::from_secs(24 * 60 * 60);
|
||||
assert_eq!(
|
||||
super::super::db::normalize_updated_at(millis_from_system_time(future).unwrap()),
|
||||
Some(future)
|
||||
);
|
||||
assert!(!super::super::super::is_within(
|
||||
future,
|
||||
now,
|
||||
super::super::super::MAX_SESSION_AGE,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
use super::*;
|
||||
|
||||
fn rollout_records(id: uuid::Uuid, cwd: &Path, source: serde_json::Value, title: &str) -> String {
|
||||
[
|
||||
json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": id,
|
||||
"cwd": cwd.display().to_string(),
|
||||
"source": source
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "event_msg",
|
||||
"payload": {"type": "user_message", "message": title}
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|record| record.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fallback_skips_excluded_sources_and_wrong_cwds() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_090_000);
|
||||
let day = session_day(root.path(), now);
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let excluded = uuid::Uuid::from_u128(10);
|
||||
let custom = uuid::Uuid::from_u128(13);
|
||||
let wrong_cwd = uuid::Uuid::from_u128(11);
|
||||
let winner = uuid::Uuid::from_u128(12);
|
||||
for (id, stored_cwd, source, age) in [
|
||||
(
|
||||
excluded,
|
||||
cwd.as_path(),
|
||||
json!({"subagent":"review"}),
|
||||
Duration::ZERO,
|
||||
),
|
||||
(
|
||||
custom,
|
||||
cwd.as_path(),
|
||||
json!({"custom":"atlas"}),
|
||||
Duration::from_millis(500),
|
||||
),
|
||||
(
|
||||
wrong_cwd,
|
||||
Path::new("/other"),
|
||||
json!("cli"),
|
||||
Duration::from_secs(1),
|
||||
),
|
||||
(
|
||||
winner,
|
||||
cwd.as_path(),
|
||||
json!("vscode"),
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
] {
|
||||
let path = day.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
|
||||
fs::write(&path, rollout_records(id, stored_cwd, source, "")).unwrap();
|
||||
touch(&path, now - age);
|
||||
}
|
||||
|
||||
let found = most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)).unwrap();
|
||||
assert_eq!(found.native_id, winner.to_string());
|
||||
assert_eq!(found.source, ForeignSessionSource::CodexVsCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fallback_fails_closed_at_directory_entry_cap() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_095_000);
|
||||
let day = session_day(root.path(), now);
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
write_recent_rollout(
|
||||
root.path(),
|
||||
&cwd,
|
||||
now,
|
||||
uuid::Uuid::from_u128(15),
|
||||
json!("cli"),
|
||||
);
|
||||
for index in 0..super::super::files::MAX_RECENT_DIRECTORY_ENTRIES {
|
||||
fs::write(day.join(format!("junk-{index:03}")), "").unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600)),
|
||||
RecentProbe::Incomplete,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_fallback_includes_old_creation_directory_with_fresh_mtime() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_097_000);
|
||||
let created_at = now - Duration::from_secs(10 * 24 * 60 * 60);
|
||||
let day = session_day(root.path(), created_at);
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let id = uuid::Uuid::from_u128(16);
|
||||
let path = rollout_path(&day, id);
|
||||
fs::write(&path, rollout_records(id, &cwd, json!("cli"), "")).unwrap();
|
||||
touch(&path, now);
|
||||
|
||||
assert_eq!(
|
||||
most_recent_in_home(root.path(), &cwd, now, Duration::from_secs(600))
|
||||
.unwrap()
|
||||
.native_id,
|
||||
id.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_bounded_plain_and_compressed_heads() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_100_000);
|
||||
let day = session_day(root.path(), now);
|
||||
let valid_day = session_day(root.path(), now - Duration::from_secs(24 * 60 * 60));
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
fs::create_dir_all(&valid_day).unwrap();
|
||||
|
||||
let plain_id = uuid::Uuid::from_u128(20);
|
||||
let plain = valid_day.join(format!("rollout-1970-05-08T12-00-00-{plain_id}.jsonl"));
|
||||
fs::write(
|
||||
&plain,
|
||||
format!(
|
||||
"{{malformed\n{}",
|
||||
rollout_records(plain_id, &cwd, json!("cli"), "filesystem title")
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&plain, now);
|
||||
|
||||
let compressed_id = uuid::Uuid::from_u128(21);
|
||||
let compressed = valid_day.join(format!(
|
||||
"rollout-1970-05-08T12-00-01-{compressed_id}.jsonl.zst"
|
||||
));
|
||||
let compressed_head = rollout_records(compressed_id, &cwd, json!("vscode"), "compressed title");
|
||||
fs::write(
|
||||
&compressed,
|
||||
zstd::encode_all(compressed_head.as_bytes(), 1).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&compressed, now - Duration::from_secs(1));
|
||||
|
||||
let malformed_id = uuid::Uuid::from_u128(22);
|
||||
let malformed = valid_day.join(format!(
|
||||
"rollout-1970-05-08T12-00-02-{malformed_id}.jsonl.zst"
|
||||
));
|
||||
fs::write(&malformed, b"not a zstd frame").unwrap();
|
||||
touch(&malformed, now - Duration::from_secs(2));
|
||||
|
||||
let concatenated_id = uuid::Uuid::from_u128(23);
|
||||
let concatenated_path = valid_day.join(format!(
|
||||
"rollout-1970-05-08T12-00-03-{concatenated_id}.jsonl.zst"
|
||||
));
|
||||
let mut concatenated = zstd::encode_all(
|
||||
json!({"type":"event_msg","payload":{"type":"user_message","message":"frame one"}})
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
concatenated.extend(
|
||||
zstd::encode_all(
|
||||
rollout_records(
|
||||
concatenated_id,
|
||||
&cwd,
|
||||
json!("cli"),
|
||||
"must not read frame two",
|
||||
)
|
||||
.as_bytes(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
fs::write(&concatenated_path, concatenated).unwrap();
|
||||
touch(&concatenated_path, now - Duration::from_secs(3));
|
||||
|
||||
let skippable_id = uuid::Uuid::from_u128(24);
|
||||
let skippable_path = valid_day.join(format!(
|
||||
"rollout-1970-05-08T12-00-04-{skippable_id}.jsonl.zst"
|
||||
));
|
||||
let payload_len = super::super::files::MAX_COMPRESSED_HEAD_BYTES + 1024;
|
||||
let mut skippable = Vec::with_capacity(payload_len + 8);
|
||||
skippable.extend(0x184D_2A50_u32.to_le_bytes());
|
||||
skippable.extend(u32::try_from(payload_len).unwrap().to_le_bytes());
|
||||
skippable.resize(8 + payload_len, 0);
|
||||
skippable.extend(
|
||||
zstd::encode_all(
|
||||
rollout_records(skippable_id, &cwd, json!("cli"), "beyond compressed cap").as_bytes(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
fs::write(&skippable_path, &skippable).unwrap();
|
||||
assert!(skippable.len() > super::super::files::MAX_COMPRESSED_HEAD_BYTES);
|
||||
touch(&skippable_path, now - Duration::from_secs(4));
|
||||
|
||||
let window_id = uuid::Uuid::from_u128(25);
|
||||
let window_path = valid_day.join(format!("rollout-1970-05-08T12-00-05-{window_id}.jsonl.zst"));
|
||||
let window_head = rollout_records(window_id, &cwd, json!("cli"), "oversized window");
|
||||
let mut encoder = zstd::Encoder::new(Vec::new(), 1).unwrap();
|
||||
encoder
|
||||
.window_log(super::super::files::MAX_ZSTD_WINDOW_LOG + 1)
|
||||
.unwrap();
|
||||
encoder.include_contentsize(false).unwrap();
|
||||
encoder.write_all(window_head.as_bytes()).unwrap();
|
||||
let window_frame = encoder.finish().unwrap();
|
||||
assert_eq!(
|
||||
zstd::decode_all(window_frame.as_slice()).unwrap(),
|
||||
window_head.as_bytes()
|
||||
);
|
||||
let mut limited = zstd::Decoder::new(window_frame.as_slice()).unwrap();
|
||||
limited
|
||||
.window_log_max(super::super::files::MAX_ZSTD_WINDOW_LOG)
|
||||
.unwrap();
|
||||
let mut limited_output = Vec::new();
|
||||
assert!(limited.read_to_end(&mut limited_output).is_err());
|
||||
fs::write(&window_path, window_frame).unwrap();
|
||||
touch(&window_path, now - Duration::from_secs(5));
|
||||
|
||||
let output_id = uuid::Uuid::from_u128(26);
|
||||
let output_path = valid_day.join(format!("rollout-1970-05-08T12-00-06-{output_id}.jsonl.zst"));
|
||||
let output_head = format!(
|
||||
"{}\n{}",
|
||||
"x".repeat(super::super::files::MAX_HEAD_BYTES + 1),
|
||||
rollout_records(output_id, &cwd, json!("cli"), "beyond output cap")
|
||||
);
|
||||
let output_frame = zstd::encode_all(output_head.as_bytes(), 1).unwrap();
|
||||
assert!(
|
||||
String::from_utf8(zstd::decode_all(output_frame.as_slice()).unwrap())
|
||||
.unwrap()
|
||||
.contains(&output_id.to_string())
|
||||
);
|
||||
fs::write(&output_path, output_frame).unwrap();
|
||||
touch(&output_path, now - Duration::from_secs(6));
|
||||
|
||||
for i in 0..520_u128 {
|
||||
let id = uuid::Uuid::from_u128(100 + i);
|
||||
let path = day.join(format!("rollout-1970-05-08T12-01-00-{id}.jsonl"));
|
||||
fs::write(
|
||||
&path,
|
||||
rollout_records(id, &cwd, json!({"subagent":"review"}), "excluded source"),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&path, now - Duration::from_secs(i as u64 + 1));
|
||||
}
|
||||
|
||||
let sessions = scan_in_home(root.path(), &cwd, now);
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert_eq!(sessions[0].native_id, plain_id.to_string());
|
||||
assert_eq!(sessions[0].title, "filesystem title");
|
||||
assert_eq!(sessions[1].native_id, compressed_id.to_string());
|
||||
assert_eq!(sessions[1].title, "compressed title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_requires_complete_matching_first_session_meta() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_150_000);
|
||||
let day = session_day(root.path(), now);
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
for (id, first_payload) in [
|
||||
(
|
||||
uuid::Uuid::from_u128(30),
|
||||
json!({"id":uuid::Uuid::from_u128(30),"source":"cli"}),
|
||||
),
|
||||
(
|
||||
uuid::Uuid::from_u128(31),
|
||||
json!({"id":uuid::Uuid::from_u128(31),"cwd":cwd.display().to_string()}),
|
||||
),
|
||||
(
|
||||
uuid::Uuid::from_u128(32),
|
||||
json!({"cwd":cwd.display().to_string(),"source":"cli"}),
|
||||
),
|
||||
(
|
||||
uuid::Uuid::from_u128(33),
|
||||
json!({"id":uuid::Uuid::from_u128(999),"cwd":cwd.display().to_string(),"source":"cli"}),
|
||||
),
|
||||
] {
|
||||
let path = day.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"));
|
||||
fs::write(
|
||||
&path,
|
||||
[
|
||||
json!({"type":"session_meta","payload":first_payload}).to_string(),
|
||||
rollout_records(id, &cwd, json!("cli"), "fork copy"),
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&path, now);
|
||||
}
|
||||
assert!(scan_in_home(root.path(), &cwd, now).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_paths_must_remain_under_approved_roots() {
|
||||
let (_tempdir, root) = canonical_tempdir();
|
||||
let sessions = root.join("sessions");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let approved_id = uuid::Uuid::from_u128(600);
|
||||
let compressed_id = uuid::Uuid::from_u128(601);
|
||||
let approved = rollout_path(&sessions, approved_id);
|
||||
let compressed_plain = rollout_path(&sessions, compressed_id);
|
||||
let compressed = PathBuf::from(format!("{}.zst", compressed_plain.display()));
|
||||
let outside = rollout_path(&root, uuid::Uuid::from_u128(602));
|
||||
let wrong_extension = sessions.join("not-a-rollout.txt");
|
||||
let directory = rollout_path(&sessions, uuid::Uuid::from_u128(603));
|
||||
fs::write(&approved, "").unwrap();
|
||||
fs::write(&compressed, "").unwrap();
|
||||
fs::write(&outside, "").unwrap();
|
||||
fs::write(&wrong_extension, "").unwrap();
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
let approved_root = ApprovedRoot::new(&root).unwrap();
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&approved.display().to_string(),
|
||||
&approved_id.to_string()
|
||||
),
|
||||
Some(dunce::canonicalize(&approved).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&compressed_plain.display().to_string(),
|
||||
&compressed_id.to_string()
|
||||
),
|
||||
Some(dunce::canonicalize(&compressed).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&outside.display().to_string(),
|
||||
&uuid::Uuid::from_u128(602).to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&wrong_extension.display().to_string(),
|
||||
&uuid::Uuid::from_u128(604).to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&directory.display().to_string(),
|
||||
&uuid::Uuid::from_u128(603).to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
let traversal = format!(
|
||||
"sessions/../{}",
|
||||
outside.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&traversal,
|
||||
&uuid::Uuid::from_u128(602).to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
let adversarial = sessions.join(format!(
|
||||
"rollout-2027-01-15T12-00-00-extra-{}.jsonl",
|
||||
uuid::Uuid::from_u128(604)
|
||||
));
|
||||
fs::write(&adversarial, "").unwrap();
|
||||
assert_eq!(rollout_id(&adversarial), None);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = rollout_path(&sessions, uuid::Uuid::from_u128(602));
|
||||
std::os::unix::fs::symlink(&outside, &link).unwrap();
|
||||
assert_eq!(
|
||||
existing_rollout_path(
|
||||
&approved_root,
|
||||
&link.display().to_string(),
|
||||
&uuid::Uuid::from_u128(602).to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn fallback_rejects_sessions_parent_symlink_escape() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let codex_home = root.path().join("codex");
|
||||
let cwd = codex_home.join("repo");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_800_300_000);
|
||||
let outside_day = session_day(outside.path(), now);
|
||||
fs::create_dir_all(&outside_day).unwrap();
|
||||
let id = uuid::Uuid::from_u128(5_000);
|
||||
let rollout = rollout_path(&outside_day, id);
|
||||
fs::write(
|
||||
&rollout,
|
||||
rollout_records(id, &cwd, json!("cli"), "outside sessions root"),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&rollout, now);
|
||||
std::os::unix::fs::symlink(outside.path().join("sessions"), codex_home.join("sessions"))
|
||||
.unwrap();
|
||||
assert!(scan_in_home(&codex_home, &cwd, now).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_dirs_include_utc_and_offset_boundary_days() {
|
||||
let root = Path::new("/sessions");
|
||||
let now = system_time_from_millis(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-01-01T00:30:00Z")
|
||||
.unwrap()
|
||||
.timestamp_millis(),
|
||||
)
|
||||
.unwrap();
|
||||
let negative = super::super::files::recent_date_dirs(root, now, -2 * 60 * 60);
|
||||
assert_eq!(negative.len(), 32);
|
||||
assert!(negative.contains(&root.join("2026/01/01")));
|
||||
assert!(negative.contains(&root.join("2025/12/31")));
|
||||
let late = system_time_from_millis(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-01-01T23:30:00Z")
|
||||
.unwrap()
|
||||
.timestamp_millis(),
|
||||
)
|
||||
.unwrap();
|
||||
let positive = super::super::files::recent_date_dirs(root, late, 2 * 60 * 60);
|
||||
assert_eq!(positive[0], root.join("2026/01/02"));
|
||||
assert!(positive.contains(&root.join("2026/01/01")));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use filetime::FileTime;
|
||||
use rusqlite::{Connection, params, types::Value as SqlValue};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::foreign_sessions::{
|
||||
canonical_tempdir, millis_from_system_time, system_time_from_millis,
|
||||
};
|
||||
|
||||
mod db;
|
||||
mod files;
|
||||
|
||||
struct DbRow<'a> {
|
||||
id: uuid::Uuid,
|
||||
rollout_path: &'a Path,
|
||||
updated_at_ms: i64,
|
||||
source: &'a str,
|
||||
cwd: &'a Path,
|
||||
title: &'a str,
|
||||
first_user_message: &'a str,
|
||||
archived: bool,
|
||||
}
|
||||
|
||||
fn create_db(path: &Path, rows: &[DbRow<'_>]) {
|
||||
let connection = Connection::open(path).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE threads (
|
||||
id TEXT,
|
||||
rollout_path TEXT,
|
||||
updated_at_ms INTEGER,
|
||||
source TEXT,
|
||||
cwd TEXT,
|
||||
title TEXT,
|
||||
first_user_message TEXT,
|
||||
archived INTEGER,
|
||||
git_branch TEXT
|
||||
);
|
||||
CREATE INDEX threads_archived_cwd_updated \
|
||||
ON threads(archived, cwd, updated_at_ms DESC);",
|
||||
)
|
||||
.unwrap();
|
||||
for row in rows {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO threads VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL)",
|
||||
params![
|
||||
row.id.to_string(),
|
||||
row.rollout_path.display().to_string(),
|
||||
row.updated_at_ms,
|
||||
row.source,
|
||||
row.cwd.display().to_string(),
|
||||
row.title,
|
||||
row.first_user_message,
|
||||
row.archived,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(path: &Path, time: SystemTime) {
|
||||
filetime::set_file_mtime(path, FileTime::from_system_time(time)).unwrap();
|
||||
}
|
||||
|
||||
fn session_day(root: &Path, now: SystemTime) -> PathBuf {
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
|
||||
let date = DateTime::<Utc>::from(now).date_naive();
|
||||
root.join("sessions")
|
||||
.join(format!("{:04}", date.year()))
|
||||
.join(format!("{:02}", date.month()))
|
||||
.join(format!("{:02}", date.day()))
|
||||
}
|
||||
|
||||
fn rollout_path(dir: &Path, id: uuid::Uuid) -> PathBuf {
|
||||
dir.join(format!("rollout-2027-01-15T12-00-00-{id}.jsonl"))
|
||||
}
|
||||
|
||||
fn write_recent_rollout(
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
now: SystemTime,
|
||||
id: uuid::Uuid,
|
||||
source: serde_json::Value,
|
||||
) -> PathBuf {
|
||||
let day = session_day(root, now);
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let path = rollout_path(&day, id);
|
||||
fs::write(
|
||||
&path,
|
||||
json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": id,
|
||||
"cwd": cwd.display().to_string(),
|
||||
"source": source,
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
touch(&path, now);
|
||||
path
|
||||
}
|
||||
|
||||
fn oversized_text(limit: usize, utf8: bool) -> String {
|
||||
if utf8 {
|
||||
"é".repeat(limit / 2 + 1)
|
||||
} else {
|
||||
"x".repeat(limit + 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! Bounded, metadata-only listing of foreign coding-agent sessions.
|
||||
//! Foreign SQLite stores are opened only when `kigi_sqlite_journal::JournalMode`
|
||||
//! selects local WAL. The direct read-only/query-only transaction makes no
|
||||
//! logical writes, though WAL coordination may update shared-memory read marks.
|
||||
//! Network filesystems fail soft before SQLite open, conversion, or writes.
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
mod capability;
|
||||
mod claude;
|
||||
mod codex;
|
||||
use capability::{ApprovedRoot, open_sqlite_transaction};
|
||||
pub const MAX_SESSIONS_PER_TOOL: usize = 50;
|
||||
pub const MAX_SESSION_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
pub const MAX_TITLE_CHARS: usize = 200;
|
||||
const MAX_FUTURE_SKEW: Duration = Duration::from_secs(5 * 60);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ForeignSessionTool {
|
||||
Claude,
|
||||
Codex,
|
||||
Cursor,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ForeignSessionSource {
|
||||
ClaudeCode,
|
||||
CodexCli,
|
||||
CodexVsCode,
|
||||
CodexAtlas,
|
||||
CodexChatGpt,
|
||||
CursorDesktop,
|
||||
CursorCli,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ForeignSessionSummary {
|
||||
pub tool: ForeignSessionTool,
|
||||
pub source: ForeignSessionSource,
|
||||
pub native_id: String,
|
||||
pub title: String,
|
||||
pub cwd: PathBuf,
|
||||
pub updated_at: SystemTime,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecentForeignSession {
|
||||
pub tool: ForeignSessionTool,
|
||||
pub native_id: String,
|
||||
pub age: Duration,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RecentCandidate {
|
||||
tool: ForeignSessionTool,
|
||||
source: ForeignSessionSource,
|
||||
native_id: String,
|
||||
updated_at: SystemTime,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum RecentProbe<T> {
|
||||
Complete(Option<T>),
|
||||
Incomplete,
|
||||
}
|
||||
fn approved_root_for_recent(path: &Path) -> Result<Option<ApprovedRoot>, ()> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(_) => Err(()),
|
||||
Ok(_) => ApprovedRoot::new(path).map(Some).ok_or(()),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl<T> RecentProbe<T> {
|
||||
fn unwrap(self) -> T {
|
||||
match self {
|
||||
Self::Complete(Some(value)) => value,
|
||||
Self::Complete(None) => {
|
||||
panic!("called RecentProbe::unwrap on complete-empty probe")
|
||||
}
|
||||
Self::Incomplete => panic!("called RecentProbe::unwrap on incomplete probe"),
|
||||
}
|
||||
}
|
||||
fn is_none(&self) -> bool {
|
||||
matches!(self, Self::Complete(None))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct EnabledForeignSessionSources {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub cursor: bool,
|
||||
}
|
||||
pub fn scan_foreign_sessions(
|
||||
cwd: &Path,
|
||||
enabled: EnabledForeignSessionSources,
|
||||
) -> Vec<ForeignSessionSummary> {
|
||||
let scan_cursor = |_: &Path, _: SystemTime| Vec::new();
|
||||
scan_with(cwd, enabled, claude::scan, codex::scan, scan_cursor)
|
||||
}
|
||||
pub fn most_recent_foreign_session(
|
||||
cwd: &Path,
|
||||
enabled: EnabledForeignSessionSources,
|
||||
within: Duration,
|
||||
) -> Option<RecentForeignSession> {
|
||||
let recent_cursor = |_: &Path, _: SystemTime, _: Duration| RecentProbe::Complete(None);
|
||||
match most_recent_with(
|
||||
cwd,
|
||||
enabled,
|
||||
within,
|
||||
SystemTime::now(),
|
||||
claude::most_recent,
|
||||
codex::most_recent,
|
||||
recent_cursor,
|
||||
) {
|
||||
RecentProbe::Complete(session) => session,
|
||||
RecentProbe::Incomplete => None,
|
||||
}
|
||||
}
|
||||
fn most_recent_with<Claude, Codex, Cursor>(
|
||||
cwd: &Path,
|
||||
enabled: EnabledForeignSessionSources,
|
||||
within: Duration,
|
||||
now: SystemTime,
|
||||
recent_claude: Claude,
|
||||
recent_codex: Codex,
|
||||
recent_cursor: Cursor,
|
||||
) -> RecentProbe<RecentForeignSession>
|
||||
where
|
||||
Claude: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
|
||||
Codex: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
|
||||
Cursor: FnOnce(&Path, SystemTime, Duration) -> RecentProbe<RecentCandidate>,
|
||||
{
|
||||
if !enabled.claude && !enabled.codex && !enabled.cursor {
|
||||
return RecentProbe::Complete(None);
|
||||
}
|
||||
let Ok(cwd) = dunce::canonicalize(cwd) else {
|
||||
return RecentProbe::Complete(None);
|
||||
};
|
||||
let mut candidates = Vec::with_capacity(3);
|
||||
if enabled.claude {
|
||||
match recent_claude(&cwd, now, within) {
|
||||
RecentProbe::Complete(candidate) => candidates.extend(candidate),
|
||||
RecentProbe::Incomplete => return RecentProbe::Incomplete,
|
||||
}
|
||||
}
|
||||
if enabled.codex {
|
||||
match recent_codex(&cwd, now, within) {
|
||||
RecentProbe::Complete(candidate) => candidates.extend(candidate),
|
||||
RecentProbe::Incomplete => return RecentProbe::Incomplete,
|
||||
}
|
||||
}
|
||||
if enabled.cursor {
|
||||
match recent_cursor(&cwd, now, within) {
|
||||
RecentProbe::Complete(candidate) => candidates.extend(candidate),
|
||||
RecentProbe::Incomplete => return RecentProbe::Incomplete,
|
||||
}
|
||||
}
|
||||
let winner = candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| is_within(candidate.updated_at, now, within))
|
||||
.min_by(recent_candidate_order);
|
||||
RecentProbe::Complete(winner.map(|winner| {
|
||||
RecentForeignSession {
|
||||
tool: winner.tool,
|
||||
native_id: winner.native_id,
|
||||
age: now
|
||||
.duration_since(winner.updated_at)
|
||||
.unwrap_or(Duration::ZERO),
|
||||
}
|
||||
}))
|
||||
}
|
||||
fn recent_candidate_order(a: &RecentCandidate, b: &RecentCandidate) -> Ordering {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.tool.cmp(&b.tool))
|
||||
.then_with(|| a.native_id.cmp(&b.native_id))
|
||||
.then_with(|| a.source.cmp(&b.source))
|
||||
}
|
||||
fn scan_with<Claude, Codex, Cursor>(
|
||||
cwd: &Path,
|
||||
enabled: EnabledForeignSessionSources,
|
||||
mut scan_claude: Claude,
|
||||
mut scan_codex: Codex,
|
||||
mut scan_cursor: Cursor,
|
||||
) -> Vec<ForeignSessionSummary>
|
||||
where
|
||||
Claude: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
|
||||
Codex: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
|
||||
Cursor: FnMut(&Path, SystemTime) -> Vec<ForeignSessionSummary>,
|
||||
{
|
||||
if !enabled.claude && !enabled.codex && !enabled.cursor {
|
||||
return Vec::new();
|
||||
}
|
||||
let canonical_cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
||||
let mut cwd_spellings = vec![canonical_cwd];
|
||||
if cwd_spellings[0].as_path() != cwd {
|
||||
cwd_spellings.push(cwd.to_path_buf());
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
let mut sessions = Vec::new();
|
||||
if enabled.claude {
|
||||
let mut tool_sessions = Vec::new();
|
||||
for cwd in &cwd_spellings {
|
||||
tool_sessions.extend(scan_claude(cwd, now));
|
||||
}
|
||||
sessions.extend(finish_tool_scan(tool_sessions));
|
||||
}
|
||||
if enabled.codex {
|
||||
let mut tool_sessions = Vec::new();
|
||||
for cwd in &cwd_spellings {
|
||||
tool_sessions.extend(scan_codex(cwd, now));
|
||||
}
|
||||
sessions.extend(finish_tool_scan(tool_sessions));
|
||||
}
|
||||
if enabled.cursor {
|
||||
let mut tool_sessions = Vec::new();
|
||||
for cwd in &cwd_spellings {
|
||||
tool_sessions.extend(scan_cursor(cwd, now));
|
||||
}
|
||||
sessions.extend(finish_tool_scan(tool_sessions));
|
||||
}
|
||||
sort_sessions(&mut sessions);
|
||||
sessions
|
||||
}
|
||||
fn sort_sessions(sessions: &mut [ForeignSessionSummary]) {
|
||||
sessions.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| a.tool.cmp(&b.tool))
|
||||
.then_with(|| a.native_id.cmp(&b.native_id))
|
||||
.then_with(|| a.source.cmp(&b.source))
|
||||
.then_with(|| a.title.cmp(&b.title))
|
||||
.then_with(|| a.cwd.cmp(&b.cwd))
|
||||
});
|
||||
}
|
||||
pub(super) fn finish_tool_scan(
|
||||
mut sessions: Vec<ForeignSessionSummary>,
|
||||
) -> Vec<ForeignSessionSummary> {
|
||||
sort_sessions(&mut sessions);
|
||||
let mut seen = HashSet::new();
|
||||
sessions.retain(|session| seen.insert(session.native_id.clone()));
|
||||
sessions.truncate(MAX_SESSIONS_PER_TOOL);
|
||||
sessions
|
||||
}
|
||||
pub(super) fn retain_top_k_by<T>(
|
||||
candidates: &mut Vec<T>,
|
||||
candidate: T,
|
||||
limit: usize,
|
||||
compare: impl Fn(&T, &T) -> Ordering,
|
||||
) {
|
||||
if limit == 0 {
|
||||
return;
|
||||
}
|
||||
if candidates.len() == limit {
|
||||
if !candidates
|
||||
.last()
|
||||
.is_some_and(|worst| compare(worst, &candidate).is_gt())
|
||||
{
|
||||
return;
|
||||
}
|
||||
candidates.pop();
|
||||
}
|
||||
let index = candidates
|
||||
.binary_search_by(|existing| compare(existing, &candidate))
|
||||
.unwrap_or_else(|index| index);
|
||||
candidates.insert(index, candidate);
|
||||
}
|
||||
pub(super) fn is_within(updated_at: SystemTime, now: SystemTime, within: Duration) -> bool {
|
||||
match now.duration_since(updated_at) {
|
||||
Ok(age) => age <= within,
|
||||
Err(future) => future.duration() <= MAX_FUTURE_SKEW,
|
||||
}
|
||||
}
|
||||
pub(super) fn system_time_from_millis(millis: i64) -> Option<SystemTime> {
|
||||
let millis = u64::try_from(millis).ok()?;
|
||||
UNIX_EPOCH.checked_add(Duration::from_millis(millis))
|
||||
}
|
||||
pub(super) fn millis_from_system_time(time: SystemTime) -> Option<i64> {
|
||||
let millis = time.duration_since(UNIX_EPOCH).ok()?.as_millis();
|
||||
i64::try_from(millis).ok()
|
||||
}
|
||||
pub(super) fn millis_bounds(now: SystemTime, within: Duration) -> Option<(i64, i64)> {
|
||||
Some((
|
||||
millis_from_system_time(now.checked_sub(within)?)?,
|
||||
millis_from_system_time(now.checked_add(MAX_FUTURE_SKEW)?)?,
|
||||
))
|
||||
}
|
||||
pub(super) fn normalize_title(value: &str) -> Option<String> {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut chars = normalized.chars();
|
||||
let prefix: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_none() {
|
||||
Some(prefix)
|
||||
} else {
|
||||
let mut truncated: String = prefix
|
||||
.chars()
|
||||
.take(MAX_TITLE_CHARS.saturating_sub(1))
|
||||
.collect();
|
||||
truncated.push('…');
|
||||
Some(truncated)
|
||||
}
|
||||
}
|
||||
/// Test fixture root: a tempdir plus its canonical path. `ApprovedRoot` (and
|
||||
/// the path-capability code built on it) canonicalizes internally, so fixtures
|
||||
/// must build paths from the canonical form or containment checks fail when
|
||||
/// the OS tempdir is behind a symlink (macOS: `/var` -> `/private/var`).
|
||||
#[cfg(test)]
|
||||
fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dunce::canonicalize(dir.path()).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
fn summary(id: &str, updated_at: SystemTime) -> ForeignSessionSummary {
|
||||
ForeignSessionSummary {
|
||||
tool: ForeignSessionTool::Claude,
|
||||
source: ForeignSessionSource::ClaudeCode,
|
||||
native_id: id.to_owned(),
|
||||
title: id.to_owned(),
|
||||
cwd: PathBuf::from("/repo"),
|
||||
updated_at,
|
||||
branch: None,
|
||||
}
|
||||
}
|
||||
fn recent_candidate(
|
||||
tool: ForeignSessionTool,
|
||||
source: ForeignSessionSource,
|
||||
id: &str,
|
||||
updated_at: SystemTime,
|
||||
) -> RecentCandidate {
|
||||
RecentCandidate {
|
||||
tool,
|
||||
source,
|
||||
native_id: id.to_owned(),
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
fn complete_candidate(
|
||||
tool: ForeignSessionTool,
|
||||
source: ForeignSessionSource,
|
||||
id: &str,
|
||||
updated_at: SystemTime,
|
||||
) -> RecentProbe<RecentCandidate> {
|
||||
RecentProbe::Complete(Some(recent_candidate(tool, source, id, updated_at)))
|
||||
}
|
||||
#[test]
|
||||
fn recent_winner_is_newest_across_tools_with_deterministic_ties() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
let within = Duration::from_secs(600);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = dunce::canonicalize(root.path()).unwrap();
|
||||
let enabled = EnabledForeignSessionSources {
|
||||
claude: true,
|
||||
codex: true,
|
||||
cursor: true,
|
||||
};
|
||||
let winner = most_recent_with(
|
||||
&cwd,
|
||||
enabled,
|
||||
within,
|
||||
now,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Claude,
|
||||
ForeignSessionSource::ClaudeCode,
|
||||
"claude",
|
||||
now - Duration::from_secs(3),
|
||||
)
|
||||
},
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Codex,
|
||||
ForeignSessionSource::CodexCli,
|
||||
"codex",
|
||||
now - Duration::from_secs(1),
|
||||
)
|
||||
},
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Cursor,
|
||||
ForeignSessionSource::CursorDesktop,
|
||||
"cursor",
|
||||
now - Duration::from_secs(2),
|
||||
)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(winner.tool, ForeignSessionTool::Codex);
|
||||
assert_eq!(winner.native_id, "codex");
|
||||
let tied = most_recent_with(
|
||||
&cwd,
|
||||
enabled,
|
||||
within,
|
||||
now,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Claude,
|
||||
ForeignSessionSource::ClaudeCode,
|
||||
"claude",
|
||||
now,
|
||||
)
|
||||
},
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Codex,
|
||||
ForeignSessionSource::CodexCli,
|
||||
"codex",
|
||||
now,
|
||||
)
|
||||
},
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Cursor,
|
||||
ForeignSessionSource::CursorDesktop,
|
||||
"cursor",
|
||||
now,
|
||||
)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tied.tool, ForeignSessionTool::Claude);
|
||||
assert_eq!(tied.native_id, "claude");
|
||||
}
|
||||
#[test]
|
||||
fn recent_window_includes_cutoff_and_clamps_safe_future_age() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
let within = Duration::from_secs(600);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = dunce::canonicalize(root.path()).unwrap();
|
||||
let claude_only = EnabledForeignSessionSources {
|
||||
claude: true,
|
||||
..Default::default()
|
||||
};
|
||||
let at_cutoff = most_recent_with(
|
||||
&cwd,
|
||||
claude_only,
|
||||
within,
|
||||
now,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Claude,
|
||||
ForeignSessionSource::ClaudeCode,
|
||||
"cutoff",
|
||||
now - within,
|
||||
)
|
||||
},
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled codex store touched") },
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(at_cutoff.age, within);
|
||||
let future = most_recent_with(
|
||||
&cwd,
|
||||
claude_only,
|
||||
within,
|
||||
now,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Claude,
|
||||
ForeignSessionSource::ClaudeCode,
|
||||
"future",
|
||||
now + MAX_FUTURE_SKEW,
|
||||
)
|
||||
},
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled codex store touched") },
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(future.age, Duration::ZERO);
|
||||
assert!(
|
||||
most_recent_with(
|
||||
&cwd,
|
||||
claude_only,
|
||||
within,
|
||||
now,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Claude,
|
||||
ForeignSessionSource::ClaudeCode,
|
||||
"too-far-future",
|
||||
now + MAX_FUTURE_SKEW + Duration::from_secs(1),
|
||||
)
|
||||
},
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> {
|
||||
panic!("disabled codex store touched")
|
||||
},
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> {
|
||||
panic!("disabled cursor store touched")
|
||||
},
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recent_scan_never_touches_disabled_tool_stores() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = dunce::canonicalize(root.path()).unwrap();
|
||||
let calls = Cell::new((0, 0, 0));
|
||||
let found = most_recent_with(
|
||||
&cwd,
|
||||
EnabledForeignSessionSources {
|
||||
codex: true,
|
||||
..Default::default()
|
||||
},
|
||||
Duration::from_secs(600),
|
||||
now,
|
||||
|_, _, _| {
|
||||
let (_, codex, cursor) = calls.get();
|
||||
calls.set((1, codex, cursor));
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
|_, _, _| {
|
||||
let (claude, _, cursor) = calls.get();
|
||||
calls.set((claude, 1, cursor));
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Codex,
|
||||
ForeignSessionSource::CodexCli,
|
||||
"codex",
|
||||
now,
|
||||
)
|
||||
},
|
||||
|_, _, _| {
|
||||
let (claude, codex, _) = calls.get();
|
||||
calls.set((claude, codex, 1));
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
);
|
||||
assert_eq!(calls.get(), (0, 1, 0));
|
||||
assert_eq!(found.unwrap().tool, ForeignSessionTool::Codex);
|
||||
}
|
||||
#[test]
|
||||
fn incomplete_enabled_tool_suppresses_cross_tool_winner() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = dunce::canonicalize(root.path()).unwrap();
|
||||
let result = most_recent_with(
|
||||
&cwd,
|
||||
EnabledForeignSessionSources {
|
||||
claude: true,
|
||||
codex: true,
|
||||
cursor: true,
|
||||
},
|
||||
Duration::from_secs(600),
|
||||
now,
|
||||
|_, _, _| RecentProbe::<RecentCandidate>::Incomplete,
|
||||
|_, _, _| {
|
||||
complete_candidate(
|
||||
ForeignSessionTool::Codex,
|
||||
ForeignSessionSource::CodexCli,
|
||||
"codex",
|
||||
now,
|
||||
)
|
||||
},
|
||||
|_, _, _| RecentProbe::Complete(None),
|
||||
);
|
||||
assert_eq!(result, RecentProbe::Incomplete);
|
||||
}
|
||||
#[test]
|
||||
fn recent_scan_normalizes_cwd_before_store_access() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
let child = cwd.join("child");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
let expected = dunce::canonicalize(&cwd).unwrap();
|
||||
let spelled = child.join("..");
|
||||
let found = most_recent_with(
|
||||
&spelled,
|
||||
EnabledForeignSessionSources {
|
||||
codex: true,
|
||||
..Default::default()
|
||||
},
|
||||
Duration::from_secs(600),
|
||||
SystemTime::now(),
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled claude store touched") },
|
||||
|received, _, _| {
|
||||
assert_eq!(received, expected);
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
|_, _, _| -> RecentProbe<RecentCandidate> { panic!("disabled cursor store touched") },
|
||||
);
|
||||
assert!(found.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn recent_scan_canonicalization_failure_never_invokes_stores() {
|
||||
let calls = Cell::new(0);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let missing = root.path().join("missing");
|
||||
let found = most_recent_with(
|
||||
&missing,
|
||||
EnabledForeignSessionSources {
|
||||
claude: true,
|
||||
codex: true,
|
||||
cursor: true,
|
||||
},
|
||||
Duration::from_secs(600),
|
||||
SystemTime::now(),
|
||||
|_, _, _| {
|
||||
calls.set(calls.get() + 1);
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
|_, _, _| {
|
||||
calls.set(calls.get() + 1);
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
|_, _, _| {
|
||||
calls.set(calls.get() + 1);
|
||||
RecentProbe::Complete(None)
|
||||
},
|
||||
);
|
||||
assert!(found.is_none());
|
||||
assert_eq!(calls.get(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn disabled_sources_do_not_invoke_scanners() {
|
||||
let sessions = scan_with(
|
||||
Path::new("/repo"),
|
||||
EnabledForeignSessionSources::default(),
|
||||
|_, _| panic!("claude scanner called"),
|
||||
|_, _| panic!("codex scanner called"),
|
||||
|_, _| panic!("cursor scanner called"),
|
||||
);
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn only_enabled_sources_are_invoked() {
|
||||
let claude_calls = Cell::new(0);
|
||||
let codex_calls = Cell::new(0);
|
||||
let cursor_calls = Cell::new(0);
|
||||
scan_with(
|
||||
Path::new("/repo"),
|
||||
EnabledForeignSessionSources {
|
||||
codex: true,
|
||||
..Default::default()
|
||||
},
|
||||
|_, _| {
|
||||
claude_calls.set(claude_calls.get() + 1);
|
||||
Vec::new()
|
||||
},
|
||||
|_, _| {
|
||||
codex_calls.set(codex_calls.get() + 1);
|
||||
Vec::new()
|
||||
},
|
||||
|_, _| {
|
||||
cursor_calls.set(cursor_calls.get() + 1);
|
||||
Vec::new()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
(claude_calls.get(), codex_calls.get(), cursor_calls.get()),
|
||||
(0, 1, 0)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn finish_scan_deduplicates_sorts_and_caps() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(1_000);
|
||||
let mut sessions = (0..55)
|
||||
.map(|i| summary(&format!("{i:02}"), now + Duration::from_secs(i)))
|
||||
.collect::<Vec<_>>();
|
||||
sessions.push(summary("54", now + Duration::from_secs(500)));
|
||||
let sessions = finish_tool_scan(sessions);
|
||||
assert_eq!(sessions.len(), MAX_SESSIONS_PER_TOOL);
|
||||
assert_eq!(sessions[0].native_id, "54");
|
||||
assert!(
|
||||
sessions
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].updated_at >= pair[1].updated_at)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn top_k_helper_uses_comparator_ties() {
|
||||
let mut candidates = Vec::with_capacity(3);
|
||||
for candidate in [(1, "z"), (3, "c"), (3, "a"), (2, "b"), (4, "d"), (3, "b")] {
|
||||
retain_top_k_by(&mut candidates, candidate, 3, |a, b| {
|
||||
b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1))
|
||||
});
|
||||
}
|
||||
assert_eq!(candidates, vec![(4, "d"), (3, "a"), (3, "b")]);
|
||||
}
|
||||
#[test]
|
||||
fn title_truncation_is_utf8_safe_and_bounded() {
|
||||
let title = normalize_title(&"é".repeat(250)).unwrap();
|
||||
assert_eq!(title.chars().count(), MAX_TITLE_CHARS);
|
||||
assert!(title.ends_with('…'));
|
||||
}
|
||||
#[test]
|
||||
fn recency_allows_thirty_days_and_only_small_future_skew() {
|
||||
let now = UNIX_EPOCH + Duration::from_secs(4_000_000);
|
||||
assert!(is_within(now - MAX_SESSION_AGE, now, MAX_SESSION_AGE));
|
||||
assert!(!is_within(
|
||||
now - MAX_SESSION_AGE - Duration::from_secs(1),
|
||||
now,
|
||||
MAX_SESSION_AGE,
|
||||
));
|
||||
assert!(is_within(now + MAX_FUTURE_SKEW, now, MAX_SESSION_AGE));
|
||||
assert!(!is_within(
|
||||
now + MAX_FUTURE_SKEW + Duration::from_secs(1),
|
||||
now,
|
||||
MAX_SESSION_AGE,
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn enabled_scanners_receive_canonical_and_supplied_cwd_spellings() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
let child = cwd.join("child");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
let expected = dunce::canonicalize(&cwd).unwrap();
|
||||
let spelled = child.join("..");
|
||||
let received = RefCell::new(Vec::new());
|
||||
scan_with(
|
||||
&spelled,
|
||||
EnabledForeignSessionSources {
|
||||
codex: true,
|
||||
..Default::default()
|
||||
},
|
||||
|_, _| panic!("claude scanner called"),
|
||||
|cwd, _| {
|
||||
received.borrow_mut().push(cwd.to_path_buf());
|
||||
Vec::new()
|
||||
},
|
||||
|_, _| panic!("cursor scanner called"),
|
||||
);
|
||||
assert_eq!(
|
||||
received.into_inner(),
|
||||
vec![expected.clone(), spelled.clone()]
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.path().join("linked-repo");
|
||||
std::os::unix::fs::symlink(&cwd, &link).unwrap();
|
||||
let received = RefCell::new(Vec::new());
|
||||
scan_with(
|
||||
&link,
|
||||
EnabledForeignSessionSources {
|
||||
cursor: true,
|
||||
..Default::default()
|
||||
},
|
||||
|_, _| panic!("claude scanner called"),
|
||||
|_, _| panic!("codex scanner called"),
|
||||
|cwd, _| {
|
||||
received.borrow_mut().push(cwd.to_path_buf());
|
||||
Vec::new()
|
||||
},
|
||||
);
|
||||
assert_eq!(received.into_inner(), vec![expected, link]);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn normalized_cwd_uses_ordinary_windows_spelling() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = root.path().join("repo");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
scan_with(
|
||||
&cwd,
|
||||
EnabledForeignSessionSources {
|
||||
codex: true,
|
||||
..Default::default()
|
||||
},
|
||||
|_, _| panic!("claude scanner called"),
|
||||
|received, _| {
|
||||
assert_eq!(received, dunce::canonicalize(&cwd).unwrap());
|
||||
assert!(!received.to_string_lossy().starts_with(r"\\?\"));
|
||||
Vec::new()
|
||||
},
|
||||
|_, _| panic!("cursor scanner called"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user