mirror of
https://github.com/openai/codex.git
synced 2026-02-03 07:23:39 +00:00
Compare commits
2 Commits
centralize
...
file-notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21ace71f45 | ||
|
|
6b05624040 |
109
codex-rs/Cargo.lock
generated
109
codex-rs/Cargo.lock
generated
@@ -1056,6 +1056,7 @@ dependencies = [
|
||||
"libc",
|
||||
"maplit",
|
||||
"mcp-types",
|
||||
"notify",
|
||||
"openssl-sys",
|
||||
"os_info",
|
||||
"portable-pty",
|
||||
@@ -1645,7 +1646,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"mio 1.0.4",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
@@ -2299,6 +2300,18 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.0"
|
||||
@@ -2371,6 +2384,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -3057,6 +3079,26 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -3257,6 +3299,26 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.19.12"
|
||||
@@ -3334,6 +3396,7 @@ checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3534,6 +3597,18 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
@@ -3656,6 +3731,25 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.1"
|
||||
@@ -5380,7 +5474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.0.4",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
@@ -5948,7 +6042,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.0.4",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
@@ -6846,6 +6940,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -76,6 +76,7 @@ codex-utils-string = { path = "utils/string" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
mcp-types = { path = "mcp-types" }
|
||||
mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
notify = "6"
|
||||
|
||||
# External
|
||||
allocative = "0.3.3"
|
||||
|
||||
@@ -35,6 +35,7 @@ futures = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
portable-pty = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -19,6 +19,10 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
|
||||
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
|
||||
## File change notifications
|
||||
|
||||
- Environment contexts may include a `<changed_files>` section listing paths that changed since your last turn. Treat this list as mandatory review input: inspect every listed file before planning or applying additional edits.
|
||||
|
||||
## Plan tool
|
||||
|
||||
When using the planning tool:
|
||||
|
||||
@@ -23,9 +23,13 @@ Your default personality and tone is concise, direct, and friendly. You communic
|
||||
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
|
||||
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
|
||||
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
|
||||
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
|
||||
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
|
||||
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
|
||||
|
||||
## File change notifications
|
||||
|
||||
- Environment contexts may include a `<changed_files>` section listing paths that changed since your last turn. Treat this list as mandatory review input: inspect every listed file before planning or applying additional edits.
|
||||
|
||||
## Responsiveness
|
||||
|
||||
### Preamble messages
|
||||
|
||||
@@ -57,6 +57,7 @@ use crate::exec_command::WriteStdinParams;
|
||||
use crate::executor::Executor;
|
||||
use crate::executor::ExecutorConfig;
|
||||
use crate::executor::normalize_exec_result;
|
||||
use crate::file_change_notifier::FileChangeWatcher;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::model_family::find_family_for_model;
|
||||
@@ -462,6 +463,14 @@ impl Session {
|
||||
is_review_mode: false,
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
let (file_change_watcher, file_change_collector) =
|
||||
match FileChangeWatcher::start(turn_context.cwd.clone()) {
|
||||
Ok((watcher, collector)) => (Some(watcher), Some(collector)),
|
||||
Err(err) => {
|
||||
warn!("file change notifications disabled: {err:#}");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager,
|
||||
session_manager: ExecSessionManager::default(),
|
||||
@@ -475,6 +484,8 @@ impl Session {
|
||||
turn_context.cwd.clone(),
|
||||
config.codex_linux_sandbox_exe.clone(),
|
||||
)),
|
||||
file_change_collector,
|
||||
_file_change_watcher: file_change_watcher,
|
||||
};
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
@@ -729,6 +740,7 @@ impl Session {
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
Some(self.user_shell().clone()),
|
||||
None,
|
||||
)));
|
||||
items
|
||||
}
|
||||
@@ -986,6 +998,13 @@ impl Session {
|
||||
self.send_event(event).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn take_changed_files(&self) -> Vec<PathBuf> {
|
||||
match &self.services.file_change_collector {
|
||||
Some(collector) => collector.drain().await,
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_stream_error(&self, sub_id: &str, message: impl Into<String>) {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
@@ -1227,6 +1246,7 @@ async fn submission_loop(
|
||||
sandbox_policy,
|
||||
// Shell is not configurable from turn to turn
|
||||
None,
|
||||
None,
|
||||
))])
|
||||
.await;
|
||||
}
|
||||
@@ -1659,6 +1679,22 @@ pub(crate) async fn run_task(
|
||||
} else {
|
||||
sess.record_input_and_rollout_usermsg(&initial_input_for_turn)
|
||||
.await;
|
||||
let changed_files = sess.take_changed_files().await;
|
||||
if !changed_files.is_empty() {
|
||||
let response_item: ResponseItem =
|
||||
EnvironmentContext::new(None, None, None, None, Some(changed_files)).into();
|
||||
sess.record_conversation_items(std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
for msg in
|
||||
map_response_item_to_event_messages(&response_item, sess.show_raw_agent_reasoning())
|
||||
{
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg,
|
||||
};
|
||||
sess.send_event(event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
@@ -2450,6 +2486,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::file_change_notifier::collector_for_tests;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::InitialHistory;
|
||||
@@ -2780,6 +2817,8 @@ mod tests {
|
||||
turn_context.cwd.clone(),
|
||||
None,
|
||||
)),
|
||||
file_change_collector: None,
|
||||
_file_change_watcher: None,
|
||||
};
|
||||
let session = Session {
|
||||
conversation_id,
|
||||
@@ -2853,6 +2892,8 @@ mod tests {
|
||||
config.cwd.clone(),
|
||||
None,
|
||||
)),
|
||||
file_change_collector: None,
|
||||
_file_change_watcher: None,
|
||||
};
|
||||
let session = Arc::new(Session {
|
||||
conversation_id,
|
||||
@@ -3004,6 +3045,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_task_emits_environment_context_with_changed_files() {
|
||||
let (mut session, turn_context) = make_session_and_context();
|
||||
let collector = collector_for_tests(turn_context.cwd.clone());
|
||||
collector
|
||||
.record_for_tests(PathBuf::from("tracked.rs"))
|
||||
.await;
|
||||
session.services.file_change_collector = Some(collector);
|
||||
session.services._file_change_watcher = None;
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let input = vec![InputItem::Text {
|
||||
text: "hello".to_string(),
|
||||
}];
|
||||
let _ = run_task(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn_context),
|
||||
"sub".to_string(),
|
||||
input,
|
||||
)
|
||||
.await;
|
||||
|
||||
let history = session.history_snapshot().await;
|
||||
let mut found = false;
|
||||
for item in history {
|
||||
if let ResponseItem::Message { content, .. } = item {
|
||||
for part in content {
|
||||
if let ContentItem::InputText { text } = part
|
||||
&& text.contains("<changed_files>")
|
||||
&& text.contains("tracked.rs")
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found, "missing changed_files environment context");
|
||||
}
|
||||
|
||||
fn sample_rollout(
|
||||
session: &Session,
|
||||
turn_context: &TurnContext,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) struct EnvironmentContext {
|
||||
pub network_access: Option<NetworkAccess>,
|
||||
pub writable_roots: Option<Vec<PathBuf>>,
|
||||
pub shell: Option<Shell>,
|
||||
pub changed_files: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl EnvironmentContext {
|
||||
@@ -37,6 +38,7 @@ impl EnvironmentContext {
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
shell: Option<Shell>,
|
||||
changed_files: Option<Vec<PathBuf>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
@@ -70,6 +72,8 @@ impl EnvironmentContext {
|
||||
_ => None,
|
||||
},
|
||||
shell,
|
||||
changed_files: changed_files
|
||||
.and_then(|paths| if paths.is_empty() { None } else { Some(paths) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@ impl EnvironmentContext {
|
||||
writable_roots,
|
||||
// should compare all fields except shell
|
||||
shell: _,
|
||||
changed_files,
|
||||
} = other;
|
||||
|
||||
self.cwd == *cwd
|
||||
@@ -92,6 +97,7 @@ impl EnvironmentContext {
|
||||
&& self.sandbox_mode == *sandbox_mode
|
||||
&& self.network_access == *network_access
|
||||
&& self.writable_roots == *writable_roots
|
||||
&& self.changed_files == *changed_files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +109,7 @@ impl From<&TurnContext> for EnvironmentContext {
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
// Shell is not configurable from turn to turn
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +162,14 @@ impl EnvironmentContext {
|
||||
{
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
}
|
||||
if let Some(changed_files) = &self.changed_files
|
||||
&& !changed_files.is_empty() {
|
||||
lines.push(" <changed_files>".to_string());
|
||||
for path in changed_files {
|
||||
lines.push(format!(" <path>{}</path>", path.to_string_lossy()));
|
||||
}
|
||||
lines.push(" </changed_files>".to_string());
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -196,6 +211,7 @@ mod tests {
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -219,6 +235,7 @@ mod tests {
|
||||
Some(AskForApproval::Never),
|
||||
Some(SandboxPolicy::ReadOnly),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -237,6 +254,7 @@ mod tests {
|
||||
Some(AskForApproval::OnFailure),
|
||||
Some(SandboxPolicy::DangerFullAccess),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -256,12 +274,14 @@ mod tests {
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::Never),
|
||||
Some(workspace_write_policy(vec!["/repo"], true)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
@@ -273,12 +293,14 @@ mod tests {
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_read_only_policy()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_workspace_write_policy()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
@@ -291,12 +313,14 @@ mod tests {
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
@@ -312,6 +336,7 @@ mod tests {
|
||||
shell_path: "/bin/bash".into(),
|
||||
bashrc_path: "/home/user/.bashrc".into(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
@@ -321,6 +346,7 @@ mod tests {
|
||||
shell_path: "/bin/zsh".into(),
|
||||
zshrc_path: "/home/user/.zshrc".into(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
|
||||
177
codex-rs/core/src/file_change_notifier.rs
Normal file
177
codex-rs/core/src/file_change_notifier.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use dunce::canonicalize;
|
||||
use notify::Config;
|
||||
use notify::Event;
|
||||
use notify::EventKind;
|
||||
use notify::RecommendedWatcher;
|
||||
use notify::RecursiveMode;
|
||||
use notify::Watcher;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileChangeCollector {
|
||||
root: Arc<PathBuf>,
|
||||
changes: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl FileChangeCollector {
|
||||
fn new(root: PathBuf) -> Self {
|
||||
Self {
|
||||
root: Arc::new(root),
|
||||
changes: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn root_path(&self) -> &Path {
|
||||
self.root.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) async fn record_event(&self, event: Event) {
|
||||
let Event { kind, paths, .. } = event;
|
||||
if should_skip(kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut normalized_paths = Vec::new();
|
||||
for path in paths {
|
||||
if let Some(rel) = self.normalize_path(&path) {
|
||||
normalized_paths.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
if normalized_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut guard = self.changes.lock().await;
|
||||
guard.extend(normalized_paths);
|
||||
}
|
||||
|
||||
fn normalize_path(&self, path: &Path) -> Option<PathBuf> {
|
||||
let rel = path.strip_prefix(self.root_path()).ok()?;
|
||||
if should_ignore(rel) {
|
||||
return None;
|
||||
}
|
||||
Some(rel.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) async fn drain(&self) -> Vec<PathBuf> {
|
||||
let mut guard = self.changes.lock().await;
|
||||
let mut entries: Vec<PathBuf> = guard.drain().collect();
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip(kind: EventKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
EventKind::Access(_) | EventKind::Other | EventKind::Any
|
||||
)
|
||||
}
|
||||
|
||||
fn should_ignore(path: &Path) -> bool {
|
||||
path.components().any(|component| {
|
||||
if let std::path::Component::Normal(part) = component {
|
||||
matches!(
|
||||
part.to_str(),
|
||||
Some(".git") | Some(".codex") | Some("target") | Some(".DS_Store") | Some(".idea")
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct FileChangeWatcher {
|
||||
_watcher: RecommendedWatcher,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FileChangeWatcher {
|
||||
pub(crate) fn start(root: PathBuf) -> Result<(Self, FileChangeCollector)> {
|
||||
let canonical_root = canonicalize(&root)
|
||||
.with_context(|| format!("failed to canonicalize {}", root.display()))?;
|
||||
let collector = FileChangeCollector::new(canonical_root.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res| {
|
||||
let _ = tx.send(res);
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.context("failed to initialize file watcher")?;
|
||||
watcher
|
||||
.watch(&canonical_root, RecursiveMode::Recursive)
|
||||
.with_context(|| format!("failed to watch path {}", canonical_root.display()))?;
|
||||
|
||||
let collector_clone = collector.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Some(event_result) = rx.recv().await {
|
||||
match event_result {
|
||||
Ok(event) => collector_clone.record_event(event).await,
|
||||
Err(err) => warn!("file watcher error: {err}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
_watcher: watcher,
|
||||
task,
|
||||
},
|
||||
collector,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileChangeWatcher {
|
||||
fn drop(&mut self) {
|
||||
if self.task.is_finished() {
|
||||
return;
|
||||
}
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn collector_for_tests(root: PathBuf) -> FileChangeCollector {
|
||||
FileChangeCollector::new(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FileChangeCollector {
|
||||
pub(crate) async fn record_for_tests(&self, path: PathBuf) {
|
||||
let mut guard = self.changes.lock().await;
|
||||
guard.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_returns_empty_when_no_changes() {
|
||||
let collector = collector_for_tests(PathBuf::from("."));
|
||||
let drained = collector.drain().await;
|
||||
assert!(drained.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_and_drain_changes() {
|
||||
let collector = collector_for_tests(PathBuf::from("/tmp/root"));
|
||||
collector.record_for_tests(PathBuf::from("file.txt")).await;
|
||||
let drained = collector.drain().await;
|
||||
assert_eq!(drained, vec![PathBuf::from("file.txt")]);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod exec;
|
||||
mod exec_command;
|
||||
pub mod exec_env;
|
||||
pub mod executor;
|
||||
mod file_change_notifier;
|
||||
mod flags;
|
||||
pub mod git_info;
|
||||
pub mod landlock;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::RolloutRecorder;
|
||||
use crate::exec_command::ExecSessionManager;
|
||||
use crate::executor::Executor;
|
||||
use crate::file_change_notifier::FileChangeCollector;
|
||||
use crate::file_change_notifier::FileChangeWatcher;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_notification::UserNotifier;
|
||||
@@ -15,4 +17,6 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) user_shell: crate::shell::Shell,
|
||||
pub(crate) show_raw_agent_reasoning: bool,
|
||||
pub(crate) executor: Executor,
|
||||
pub(crate) file_change_collector: Option<FileChangeCollector>,
|
||||
pub(crate) _file_change_watcher: Option<FileChangeWatcher>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user