ralph_workflow/pipeline/idle_timeout/
file_activity.rs1use crate::workspace::Workspace;
8use std::path::Path;
9use std::time::{Duration, SystemTime};
10
11pub struct FileActivityTracker {
12 _private: (),
13}
14
15impl FileActivityTracker {
16 #[must_use]
17 pub const fn new() -> Self {
18 Self { _private: () }
19 }
20
21 pub fn check_for_recent_activity(
22 &self,
23 workspace: &dyn Workspace,
24 timeout: Duration,
25 now: SystemTime,
26 ) -> std::io::Result<bool> {
27 check_for_recent_activity_with_time(workspace, timeout, now)
28 }
29
30 fn is_ai_generated_file(path: &Path) -> bool {
31 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
32 return false;
33 };
34
35 let has_excluded_ext = path.extension().is_some_and(|ext| {
36 ext.eq_ignore_ascii_case("log")
37 || ext.eq_ignore_ascii_case("swp")
38 || ext.eq_ignore_ascii_case("tmp")
39 || ext.eq_ignore_ascii_case("bak")
40 });
41
42 if has_excluded_ext
43 || file_name == "checkpoint.json"
44 || file_name == "start_commit"
45 || file_name == "review_baseline.txt"
46 || file_name.ends_with('~')
47 {
48 return false;
49 }
50
51 matches!(
52 file_name,
53 "PLAN.md" | "ISSUES.md" | "NOTES.md" | "STATUS.md" | "commit-message.txt"
54 )
55 }
56
57 pub(super) fn is_excluded_workspace_dir(path: &Path) -> bool {
58 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
59 return false;
60 };
61 matches!(name, ".git" | "target" | "tmp" | "node_modules" | ".agent")
62 }
63
64 pub(super) fn is_excluded_workspace_file(path: &Path) -> bool {
65 let has_excluded_ext = path.extension().is_some_and(|ext| {
66 ext.eq_ignore_ascii_case("log")
67 || ext.eq_ignore_ascii_case("swp")
68 || ext.eq_ignore_ascii_case("tmp")
69 || ext.eq_ignore_ascii_case("bak")
70 });
71 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
72 has_excluded_ext || file_name.ends_with('~')
73 }
74}
75
76impl Default for FileActivityTracker {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82#[derive(Clone, Copy)]
83struct ScanState {
84 found_recent_activity: bool,
85 warned_unreadable_dir: bool,
86}
87
88fn file_age(now: SystemTime, mtime: SystemTime) -> Duration {
89 now.duration_since(mtime).unwrap_or(Duration::ZERO)
90}
91
92const MAX_SCAN_DEPTH: usize = 8;
93
94#[inline(never)]
95pub(crate) fn scan_dir_recursive(
96 workspace: &dyn Workspace,
97 dir: &Path,
98 now: SystemTime,
99 timeout: Duration,
100 remaining_depth: usize,
101 is_root: bool,
102) -> std::io::Result<bool> {
103 scan_dir_recursive_with_state(
104 workspace,
105 dir,
106 now,
107 timeout,
108 remaining_depth,
109 is_root,
110 false,
111 )
112 .map(|state| state.found_recent_activity)
113}
114
115#[inline(never)]
116#[expect(
117 clippy::print_stderr,
118 reason = "diagnostic warning for filesystem issues"
119)]
120fn scan_dir_recursive_with_state(
121 workspace: &dyn Workspace,
122 dir: &Path,
123 now: SystemTime,
124 timeout: Duration,
125 remaining_depth: usize,
126 is_root: bool,
127 warned_unreadable_dir: bool,
128) -> std::io::Result<ScanState> {
129 let entries = match workspace.read_dir(dir) {
130 Ok(entries) => entries,
131 Err(e) => {
132 if is_root {
133 return Err(e);
134 }
135
136 if !warned_unreadable_dir {
137 eprintln!(
138 "Warning: workspace scan skipped unreadable directory '{}' ({e}); file-activity detection may be incomplete",
139 dir.display()
140 );
141 }
142
143 return Ok(ScanState {
144 found_recent_activity: false,
145 warned_unreadable_dir: true,
146 });
147 }
148 };
149
150 entries.into_iter().try_fold(
151 ScanState {
152 found_recent_activity: false,
153 warned_unreadable_dir,
154 },
155 |state, entry| {
156 if state.found_recent_activity {
157 return Ok(state);
158 }
159
160 let path = entry.path();
161 if entry.is_file() {
162 let found_recent_activity = !FileActivityTracker::is_excluded_workspace_file(path)
163 && entry
164 .modified()
165 .is_some_and(|mtime| file_age(now, mtime) <= timeout);
166 return Ok(ScanState {
167 found_recent_activity,
168 warned_unreadable_dir: state.warned_unreadable_dir,
169 });
170 }
171
172 if entry.is_dir() {
173 if FileActivityTracker::is_excluded_workspace_dir(path) {
174 return Ok(state);
175 }
176
177 return remaining_depth
178 .checked_sub(1)
179 .map_or(Ok(state), |remaining| {
180 scan_dir_recursive_with_state(
181 workspace,
182 entry.path(),
183 now,
184 timeout,
185 remaining,
186 false,
187 state.warned_unreadable_dir,
188 )
189 });
190 }
191
192 Ok(state)
193 },
194 )
195}
196
197pub(crate) const MAX_SCAN_DEPTH_CONST: usize = MAX_SCAN_DEPTH;
198
199pub(crate) fn check_for_recent_activity_with_time(
200 workspace: &dyn Workspace,
201 timeout: Duration,
202 now: SystemTime,
203) -> std::io::Result<bool> {
204 let agent_dir = Path::new(".agent");
205
206 if workspace.exists(agent_dir) {
207 let entries = workspace.read_dir(agent_dir)?;
208
209 let has_recent_activity = entries
210 .into_iter()
211 .filter(|entry| entry.is_file())
212 .filter_map(|entry| {
213 let path = entry.path();
214 if !FileActivityTracker::is_ai_generated_file(path) {
215 return None;
216 }
217 entry.modified().map(|mtime| (path.to_path_buf(), mtime))
218 })
219 .any(|(_, mtime)| file_age(now, mtime) <= timeout);
220
221 if has_recent_activity {
222 return Ok(true);
223 }
224 }
225
226 let tmp_dir = Path::new(".agent/tmp");
227 if workspace.exists(tmp_dir) {
228 if let Ok(tmp_entries) = workspace.read_dir(tmp_dir) {
229 let has_recent_xml = tmp_entries
230 .into_iter()
231 .filter(|entry| entry.is_file())
232 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "xml"))
233 .filter_map(|entry| entry.modified())
234 .any(|mtime| file_age(now, mtime) <= timeout);
235
236 if has_recent_xml {
237 return Ok(true);
238 }
239 }
240 }
241
242 if scan_dir_recursive(
243 workspace,
244 Path::new(""),
245 now,
246 timeout,
247 MAX_SCAN_DEPTH_CONST,
248 true,
249 )? {
250 return Ok(true);
251 }
252
253 Ok(false)
254}