Skip to main content

theway_daemon/executor/
local.rs

1//! `LocalExecutor` — reference [`ToolExecutor`] backed by the local filesystem
2//! (`tokio::fs`) and process table (`tokio::process`), openspec change
3//! `sdk-split-local-sandbox` (design decision 1/6: local editing mode, the default).
4//!
5//! Behavior mirrors the daemon's tool implementations
6//! (`theway-daemon/src/tools/{read,write,bash,ls,grep,find,git}.rs`):
7//!
8//! - `read_file` reads UTF-8 text; `write_file` overwrites and creates missing
9//!   parent directories (like the `write` tool).
10//! - `run_command` spawns `argv[0]` with `argv[1..]` in `cwd`, capturing stdout and
11//!   stderr concurrently (`wait_with_output` drains both pipes, so a full stderr pipe
12//!   cannot deadlock the wait, per the `bash` tool's concurrency invariant). On timeout
13//!   the child is killed (`kill_on_drop` backstop, like the `bash` tool) and the call
14//!   returns `CommandOutput { exit_code: -1 }` — it never hangs and never leaves the
15//!   child running.
16//! - `list_dir` lists entry names sorted alphabetically, dotfiles included, no
17//!   recursive walk (like the `ls` tool).
18//! - `grep` / `find` walk with the `ignore` crate honoring `.gitignore` / hidden-file
19//!   filters and result caps (like the `grep` / `find` tools).
20//! - `git` shells out to the system `git` binary in the executor's repository context
21//!   ([`LocalExecutor::cwd`]), like the `git` tool.
22//!
23//! Relative paths are resolved against [`LocalExecutor::cwd`] (the process working
24//! directory by default), so an executor built with [`LocalExecutor::with_cwd`] stays
25//! deterministic regardless of the caller's process cwd.
26
27use std::path::{Path, PathBuf};
28use std::process::Stdio;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::Duration;
31
32use async_trait::async_trait;
33use ignore::WalkBuilder;
34use regex::Regex;
35use theway_core::executor::{CommandOutput, ExecutorError, ExecutorKind, Result, ToolExecutor};
36
37/// Default wall-clock timeout for `git` invocations (mirrors the `bash` tool's
38/// `DEFAULT_TIMEOUT_SECS`).
39const GIT_TIMEOUT: Duration = Duration::from_secs(60);
40
41/// Cap on matches returned by [`LocalExecutor::grep`] (mirrors the `grep` tool's
42/// `DEFAULT_MAX_RESULTS`).
43const MAX_GREP_MATCHES: usize = 100;
44
45/// Cap on files scanned by [`LocalExecutor::grep`] (mirrors the `grep` tool's
46/// `DEFAULT_MAX_FILES`).
47const MAX_GREP_FILES: usize = 5_000;
48
49/// Cap on paths returned by [`LocalExecutor::find`] (mirrors the `find` tool's
50/// `DEFAULT_LIMIT`).
51const MAX_FIND_PATHS: usize = 200;
52
53/// Unique suffix for atomic-write temp files. Process id disambiguates across
54/// parallel agent processes sharing one working tree (issue #17); the counter
55/// disambiguates within a process.
56static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
57
58/// Write `content` to `path` atomically: a uniquely-named temp file in the
59/// same directory, then `rename` over the target. Concurrent writers can no
60/// longer interleave (torn files) or collide on a shared temp name (the
61/// tmp+rename race seen with parallel agents). Readers see either the old or
62/// the new content, never a mix.
63///
64/// Symlinks: `rename` would replace the link itself, so a symlink target is
65/// resolved first (write-through semantics, matching the previous direct
66/// write). A broken symlink falls back to the direct write (which recreates
67/// the link target if its parent exists).
68async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> {
69    let target = match tokio::fs::symlink_metadata(path).await {
70        Ok(meta) if meta.file_type().is_symlink() => match tokio::fs::canonicalize(path).await {
71            Ok(real) => real,
72            Err(_) => {
73                return tokio::fs::write(path, content).await;
74            }
75        },
76        _ => path.to_path_buf(),
77    };
78
79    let file_name = target.file_name().ok_or_else(|| {
80        std::io::Error::new(
81            std::io::ErrorKind::InvalidInput,
82            format!("path has no file name: {}", target.display()),
83        )
84    })?;
85    let tmp_path = target.with_file_name(format!(
86        ".{}.theway-tmp-{}-{}",
87        file_name.to_string_lossy(),
88        std::process::id(),
89        TMP_COUNTER.fetch_add(1, Ordering::Relaxed),
90    ));
91
92    tokio::fs::write(&tmp_path, content).await?;
93    if let Err(e) = tokio::fs::rename(&tmp_path, &target).await {
94        let _ = tokio::fs::remove_file(&tmp_path).await;
95        return Err(e);
96    }
97    Ok(())
98}
99
100/// Reference [`ToolExecutor`] for local editing mode: local filesystem + process
101/// table. Cheap to construct, stateless apart from the repository context path,
102/// safe to share as `Arc<dyn ToolExecutor>`.
103#[derive(Debug, Clone)]
104pub struct LocalExecutor {
105    /// Repository context: base for relative paths and cwd of `git` invocations.
106    /// Defaults to the process working directory.
107    cwd: PathBuf,
108}
109
110impl Default for LocalExecutor {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl LocalExecutor {
117    /// Local executor rooted at the process working directory.
118    pub fn new() -> Self {
119        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
120        Self { cwd }
121    }
122
123    /// Local executor rooted at `cwd` (base for relative paths and `git` calls).
124    pub fn with_cwd(cwd: impl Into<PathBuf>) -> Self {
125        Self { cwd: cwd.into() }
126    }
127
128    /// The repository context this executor resolves relative paths and `git`
129    /// invocations against.
130    pub fn cwd(&self) -> &Path {
131        &self.cwd
132    }
133
134    /// Resolve `path` against the executor's repository context (absolute paths pass
135    /// through unchanged).
136    fn resolve(&self, path: &Path) -> PathBuf {
137        if path.is_absolute() {
138            path.to_path_buf()
139        } else {
140            self.cwd.join(path)
141        }
142    }
143}
144
145/// Spawn `program args` in `cwd` with piped stdio and a wall-clock `timeout`.
146///
147/// Timeout semantics (mirrors the `bash` tool): on expiry the child is killed and the
148/// call returns [`CommandOutput`] with `exit_code == -1` and empty stdout/stderr — the
149/// kill is a hard backstop (`kill_on_drop(true)`), so no branch can leak a running
150/// child or hang on its pipes.
151async fn spawn_and_wait(
152    program: &str,
153    args: &[String],
154    cwd: &Path,
155    timeout: Duration,
156) -> Result<CommandOutput> {
157    let mut cmd = tokio::process::Command::new(program);
158    cmd.args(args)
159        .current_dir(cwd)
160        .stdout(Stdio::piped())
161        .stderr(Stdio::piped())
162        .kill_on_drop(true);
163    let child = cmd
164        .spawn()
165        .map_err(|e| ExecutorError::Other(format!("spawn {program}: {e}")))?;
166
167    // `wait_with_output` drains stdout and stderr concurrently (no pipe deadlock).
168    // Dropping the future on timeout drops the `Child`, whose `kill_on_drop` kill
169    // reaches the direct child; the process-group killpg treatment of the `bash` tool
170    // lives one layer up (tool runtime) and is not needed for the executor seam.
171    let wait = child.wait_with_output();
172    let output = tokio::select! {
173        r = wait => r.map_err(|e| ExecutorError::Other(format!("wait {program}: {e}")))?,
174        () = tokio::time::sleep(timeout) => {
175            return Ok(CommandOutput {
176                stdout: String::new(),
177                stderr: String::new(),
178                exit_code: -1,
179            });
180        }
181    };
182    Ok(CommandOutput {
183        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
184        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
185        exit_code: output.status.code().unwrap_or(-1),
186    })
187}
188
189#[async_trait]
190impl ToolExecutor for LocalExecutor {
191    async fn kind(&self) -> ExecutorKind {
192        ExecutorKind::Local
193    }
194
195    async fn read_file(&self, path: &Path) -> Result<String> {
196        let path = self.resolve(path);
197        tokio::fs::read_to_string(&path)
198            .await
199            .map_err(|e| ExecutorError::Other(format!("read {}: {e}", path.display())))
200    }
201
202    async fn write_file(&self, path: &Path, content: &str) -> Result<()> {
203        let path = self.resolve(path);
204        // Parent-directory creation mirrors the `write` tool.
205        if let Some(parent) = path.parent()
206            && !parent.as_os_str().is_empty()
207        {
208            tokio::fs::create_dir_all(parent).await.map_err(|e| {
209                ExecutorError::Other(format!("create_dir_all {}: {e}", parent.display()))
210            })?;
211        }
212        atomic_write(&path, content.as_bytes())
213            .await
214            .map_err(|e| ExecutorError::Other(format!("write {}: {e}", path.display())))
215    }
216
217    async fn run_command(
218        &self,
219        cwd: &Path,
220        argv: &[String],
221        timeout: Duration,
222    ) -> Result<CommandOutput> {
223        let Some((program, args)) = argv.split_first() else {
224            return Err(ExecutorError::Other("run_command: empty argv".into()));
225        };
226        spawn_and_wait(program, args, &self.resolve(cwd), timeout).await
227    }
228
229    async fn list_dir(&self, path: &Path) -> Result<Vec<String>> {
230        let path = self.resolve(path);
231        let mut rd = tokio::fs::read_dir(&path)
232            .await
233            .map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?;
234        let mut names = Vec::new();
235        while let Some(entry) = rd
236            .next_entry()
237            .await
238            .map_err(|e| ExecutorError::Other(format!("list_dir {}: {e}", path.display())))?
239        {
240            names.push(entry.file_name().to_string_lossy().into_owned());
241        }
242        // Alphabetical order, dotfiles included — same ordering contract as the `ls` tool.
243        names.sort();
244        Ok(names)
245    }
246
247    async fn grep(&self, pattern: &str, path: &Path) -> Result<Vec<String>> {
248        let re = Regex::new(pattern)
249            .map_err(|e| ExecutorError::Other(format!("grep: invalid regex {pattern:?}: {e}")))?;
250        let path = self.resolve(path);
251        // The `ignore` walk is synchronous; keep it off the async runtime like the
252        // `grep` tool does.
253        tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
254            let walker = WalkBuilder::new(&path)
255                .standard_filters(true)
256                .hidden(true)
257                .build();
258            let mut out = Vec::new();
259            let mut files_scanned = 0usize;
260            for entry in walker {
261                let Ok(entry) = entry else { continue };
262                if !entry.file_type().is_some_and(|t| t.is_file()) {
263                    continue;
264                }
265                files_scanned += 1;
266                if files_scanned > MAX_GREP_FILES {
267                    break;
268                }
269                let p = entry.path();
270                // Binary or unreadable files are skipped, like the `grep` tool.
271                let Ok(body) = std::fs::read_to_string(p) else {
272                    continue;
273                };
274                for (i, line) in body.lines().enumerate() {
275                    if re.is_match(line) {
276                        out.push(format!("{}:{}:{line}", p.display(), i + 1));
277                        if out.len() >= MAX_GREP_MATCHES {
278                            return Ok(out);
279                        }
280                    }
281                }
282            }
283            Ok(out)
284        })
285        .await
286        .map_err(|e| ExecutorError::Other(format!("grep: spawn_blocking: {e}")))?
287    }
288
289    async fn find(&self, glob: &str, path: &Path) -> Result<Vec<String>> {
290        let glob = glob.to_string();
291        let path = self.resolve(path);
292        // The `ignore` walk is synchronous; keep it off the async runtime like the
293        // `find` tool does.
294        tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
295            let mut tb = ignore::types::TypesBuilder::new();
296            tb.add("g", &glob)
297                .map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
298            tb.select("g");
299            let types = tb
300                .build()
301                .map_err(|e| ExecutorError::Other(format!("find: invalid glob {glob:?}: {e}")))?;
302            let walker = WalkBuilder::new(&path)
303                .standard_filters(true)
304                .types(types)
305                .build();
306            let mut paths = Vec::new();
307            for entry in walker {
308                let Ok(entry) = entry else { continue };
309                if !entry.file_type().is_some_and(|t| t.is_file()) {
310                    continue;
311                }
312                if paths.len() >= MAX_FIND_PATHS {
313                    break;
314                }
315                paths.push(entry.path().display().to_string());
316            }
317            Ok(paths)
318        })
319        .await
320        .map_err(|e| ExecutorError::Other(format!("find: spawn_blocking: {e}")))?
321    }
322
323    async fn git(&self, args: &[String]) -> Result<CommandOutput> {
324        if args.is_empty() {
325            return Err(ExecutorError::Other("git: missing args".into()));
326        }
327        // The system `git` binary in the executor's repository context, like the
328        // `git` tool (which leaves cwd to the agent default when unset).
329        spawn_and_wait("git", args, &self.cwd, GIT_TIMEOUT).await
330    }
331}
332
333#[cfg(test)]
334tests_bridge_macro::tests_bridge!("executor/local");