返回 CodeWhale
diagnostics.rs
根目录 / crates / tui / src / runtime_api / diagnostics.rs
1 //! Crash/log inspection for native clients (APPS-103).
2 //!
3 //! This is a read surface, not a telemetry store: it lists and serves files
4 //! the runtime already writes to disk — `logs/` rolling logs, `audit.log`,
5 //! and `crashes/*.log` panic dumps — so a client (including a remote or
6 //! headless one that cannot see the disk) can package an export locally.
7 //! There is deliberately no upload route and no second log store.
8 //!
9 //! Routes:
10 //! GET /v1/logs — recent log/audit entries (name, size, modified)
11 //! GET /v1/logs/{name} — bounded window of one file (?offset, ?limit, ?tail)
12 //! GET /v1/crashes — crash-dump entries
13 //! GET /v1/crashes/{name} — bounded window of one dump
14 //! GET /v1/process — pid, start time, uptime, version, RSS (Linux)
15
16 use std::fs::File;
17 use std::io::{Read as _, Seek as _, SeekFrom};
18 use std::path::{Path as FsPath, PathBuf};
19 use std::sync::OnceLock;
20 use std::time::{Instant, SystemTime};
21
22 use axum::Json;
23 use axum::extract::{Path, Query, State};
24 use serde::{Deserialize, Serialize};
25 use serde_json::{Value, json};
26
27 use super::workspace::encode_window;
28 use super::{ApiError, RuntimeApiState};
29
30 /// Default read window for a file entry.
31 const READ_LIMIT_DEFAULT: usize = 256 * 1024;
32 const READ_LIMIT_MAX: usize = 4 * 1024 * 1024;
33 /// Listing caps — newest first, bounded so a long-lived install cannot
34 /// produce an unbounded response.
35 const LOG_LIST_CAP: usize = 64;
36 const CRASH_LIST_CAP: usize = 64;
37
38 /// When this API server came up. `build_router` stamps it once so process
39 /// facts describe the serving process, not first-call time.
40 static SERVER_STARTED: OnceLock<(SystemTime, Instant)> = OnceLock::new();
41
42 pub(super) fn mark_server_started() {
43 let _ = SERVER_STARTED.set((SystemTime::now(), Instant::now()));
44 }
45
46 // ---------------------------------------------------------------------------
47 // Shared listing + bounded read
48 // ---------------------------------------------------------------------------
49
50 #[derive(Debug, Serialize)]
51 struct FileEntry {
52 name: String,
53 size: u64,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 modified: Option<String>,
56 }
57
58 #[derive(Deserialize)]
59 #[serde(deny_unknown_fields)]
60 pub(super) struct FileReadQuery {
61 offset: Option<u64>,
62 limit: Option<usize>,
63 /// Convenience tail read: last N bytes of the file.
64 tail: Option<u64>,
65 }
66
67 fn rfc3339(time: SystemTime) -> String {
68 chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
69 }
70
71 /// Basenames only — a `{name}` path segment must never reach outside the
72 /// listing directory. Refuse anything that is not a plain file name.
73 fn safe_entry_name(raw: &str) -> Result<String, ApiError> {
74 let name = raw.trim();
75 if name.is_empty()
76 || name.len() > 255
77 || name.contains('/')
78 || name.contains('\\')
79 || name.contains('\0')
80 || name == "."
81 || name == ".."
82 {
83 return Err(ApiError::bad_request("name must be a file name"));
84 }
85 Ok(name.to_string())
86 }
87
88 fn list_files(dir: &FsPath, cap: usize) -> Vec<FileEntry> {
89 let mut entries: Vec<FileEntry> = Vec::new();
90 if let Ok(read_dir) = std::fs::read_dir(dir) {
91 for entry in read_dir.flatten() {
92 let Ok(file_type) = entry.file_type() else {
93 continue;
94 };
95 if !file_type.is_file() {
96 continue;
97 }
98 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
99 continue;
100 };
101 let Ok(metadata) = entry.metadata() else {
102 continue;
103 };
104 entries.push(FileEntry {
105 name,
106 size: metadata.len(),
107 modified: metadata.modified().ok().map(rfc3339),
108 });
109 }
110 }
111 entries.sort_by(|a, b| {
112 b.modified
113 .cmp(&a.modified)
114 .then_with(|| a.name.cmp(&b.name))
115 });
116 entries.truncate(cap);
117 entries
118 }
119
120 /// Read `[offset, offset + limit)` of a named file inside `dir` without
121 /// loading the whole file. Symlinks are never followed.
122 fn read_named_window(dir: &FsPath, name: &str, query: FileReadQuery) -> Result<Value, ApiError> {
123 let path = dir.join(name);
124 let metadata = std::fs::symlink_metadata(&path).map_err(|error| match error.kind() {
125 std::io::ErrorKind::NotFound => ApiError::not_found("file not found"),
126 _ => ApiError::internal(format!("file access failed: {error}")),
127 })?;
128 if metadata.file_type().is_symlink() || !metadata.is_file() {
129 return Err(ApiError::forbidden("not a regular file"));
130 }
131 let size = metadata.len();
132 let limit = query.limit.unwrap_or(READ_LIMIT_DEFAULT);
133 if !(1..=READ_LIMIT_MAX).contains(&limit) {
134 return Err(ApiError::bad_request(format!(
135 "limit must be between 1 and {READ_LIMIT_MAX} bytes"
136 )));
137 }
138 let offset = match (query.offset, query.tail) {
139 (Some(_), Some(_)) => {
140 return Err(ApiError::bad_request(
141 "offset and tail are mutually exclusive",
142 ));
143 }
144 (Some(offset), None) => offset.min(size),
145 (None, Some(tail)) => size.saturating_sub(tail.min(size)),
146 (None, None) => 0,
147 };
148 let mut file = File::open(&path)
149 .map_err(|error| ApiError::internal(format!("file open failed: {error}")))?;
150 file.seek(SeekFrom::Start(offset))
151 .map_err(|error| ApiError::internal(format!("file seek failed: {error}")))?;
152 let mut window = Vec::with_capacity(limit.min(64 * 1024));
153 file.take(limit as u64)
154 .read_to_end(&mut window)
155 .map_err(|error| ApiError::internal(format!("file read failed: {error}")))?;
156 let truncated = offset as usize + window.len() < size as usize;
157 let (encoding, content) = encode_window(&window);
158 Ok(json!({
159 "name": name,
160 "size": size,
161 "modified": metadata.modified().ok().map(rfc3339),
162 "offset": offset,
163 "bytes": window.len(),
164 "truncated": truncated,
165 "encoding": encoding,
166 "content": content,
167 }))
168 }
169
170 // ---------------------------------------------------------------------------
171 // Directories
172 // ---------------------------------------------------------------------------
173
174 /// Log files live under `runtime_log::log_directory()`; the audit trail sits
175 /// beside them at `<codewhale home>/audit.log[.1]` and is listed as extra
176 /// entries so one listing covers every text log the runtime writes.
177 fn log_sources() -> Vec<(PathBuf, Vec<PathBuf>)> {
178 let mut sources = Vec::new();
179 if let Some(dir) = crate::runtime_log::log_directory() {
180 let mut singles = Vec::new();
181 if let Ok(home) = codewhale_config::codewhale_home() {
182 for name in ["audit.log", "audit.log.1"] {
183 let path = home.join(name);
184 if path.is_file() {
185 singles.push(path);
186 }
187 }
188 }
189 sources.push((dir, singles));
190 }
191 sources
192 }
193
194 /// Panic dumps prefer `<home>/.codewhale/crashes` and fall back to the legacy
195 /// `.deepseek` directory — mirror the writer's preference order and merge
196 /// every directory that exists.
197 fn crash_dirs() -> Vec<PathBuf> {
198 let mut dirs = Vec::new();
199 if let Some(home) = crate::config::effective_home_dir() {
200 for base in [".codewhale", ".deepseek"] {
201 let dir = home.join(base).join("crashes");
202 if dir.is_dir() && !dirs.contains(&dir) {
203 dirs.push(dir);
204 }
205 }
206 }
207 dirs
208 }
209
210 // ---------------------------------------------------------------------------
211 // Routes
212 // ---------------------------------------------------------------------------
213
214 pub(super) async fn list_logs(State(_state): State<RuntimeApiState>) -> Json<Value> {
215 // Directory walks and per-file stats are blocking syscalls, and a
216 // diagnostics read must never park a Tokio worker — least of all while
217 // the thing being diagnosed is the runtime's responsiveness (#6149).
218 let sources = tokio::task::spawn_blocking(list_log_sources)
219 .await
220 .unwrap_or_default();
221 Json(json!({ "sources": sources }))
222 }
223
224 fn list_log_sources() -> Vec<Value> {
225 let mut sources = Vec::new();
226 for (dir, singles) in log_sources() {
227 let mut entries = list_files(&dir, LOG_LIST_CAP);
228 for path in singles {
229 if let Ok(metadata) = std::fs::symlink_metadata(&path)
230 && metadata.is_file()
231 && !metadata.file_type().is_symlink()
232 && let Some(name) = path.file_name().and_then(|name| name.to_str())
233 {
234 entries.push(FileEntry {
235 name: name.to_string(),
236 size: metadata.len(),
237 modified: metadata.modified().ok().map(rfc3339),
238 });
239 }
240 }
241 entries.sort_by(|a, b| {
242 b.modified
243 .cmp(&a.modified)
244 .then_with(|| a.name.cmp(&b.name))
245 });
246 entries.truncate(LOG_LIST_CAP);
247 sources.push(json!({
248 "dir": dir,
249 "files": entries,
250 }));
251 }
252 sources
253 }
254
255 pub(super) async fn read_log(
256 State(_state): State<RuntimeApiState>,
257 Path(name): Path<String>,
258 Query(query): Query<FileReadQuery>,
259 ) -> Result<Json<Value>, ApiError> {
260 let name = safe_entry_name(&name)?;
261 let body = tokio::task::spawn_blocking(move || {
262 // The audit trail is listed beside the log dir; resolve it from the
263 // codewhale home rather than the log directory.
264 if name == "audit.log" || name == "audit.log.1" {
265 let home = codewhale_config::codewhale_home()
266 .map_err(|error| ApiError::internal(format!("home unavailable: {error}")))?;
267 return read_named_window(&home, &name, query);
268 }
269 let dir = crate::runtime_log::log_directory()
270 .ok_or_else(|| ApiError::not_found("no log directory"))?;
271 read_named_window(&dir, &name, query)
272 })
273 .await
274 .map_err(|_| ApiError::internal("log read failed"))??;
275 Ok(Json(body))
276 }
277
278 pub(super) async fn list_crashes(State(_state): State<RuntimeApiState>) -> Json<Value> {
279 // Same reason as `list_logs`: `list_files` stats every entry.
280 let sources = tokio::task::spawn_blocking(list_crash_sources)
281 .await
282 .unwrap_or_default();
283 Json(json!({ "sources": sources }))
284 }
285
286 fn list_crash_sources() -> Vec<Value> {
287 let mut sources = Vec::new();
288 for dir in crash_dirs() {
289 sources.push(json!({
290 "dir": dir,
291 "files": list_files(&dir, CRASH_LIST_CAP),
292 }));
293 }
294 sources
295 }
296
297 pub(super) async fn read_crash(
298 State(_state): State<RuntimeApiState>,
299 Path(name): Path<String>,
300 Query(query): Query<FileReadQuery>,
301 ) -> Result<Json<Value>, ApiError> {
302 let name = safe_entry_name(&name)?;
303 let body = tokio::task::spawn_blocking(move || {
304 for dir in crash_dirs() {
305 let candidate = dir.join(&name);
306 if std::fs::symlink_metadata(&candidate)
307 .map(|m| m.is_file() && !m.file_type().is_symlink())
308 .unwrap_or(false)
309 {
310 return read_named_window(&dir, &name, query);
311 }
312 }
313 Err(ApiError::not_found("crash capture not found"))
314 })
315 .await
316 .map_err(|_| ApiError::internal("crash read failed"))??;
317 Ok(Json(body))
318 }
319
320 // ---------------------------------------------------------------------------
321 // GET /v1/process
322 // ---------------------------------------------------------------------------
323
324 #[cfg(target_os = "linux")]
325 fn rss_bytes() -> Option<u64> {
326 let status = std::fs::read_to_string("/proc/self/status").ok()?;
327 let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
328 let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
329 Some(kb * 1024)
330 }
331
332 #[cfg(not(target_os = "linux"))]
333 fn rss_bytes() -> Option<u64> {
334 None
335 }
336
337 pub(super) async fn process_info(State(_state): State<RuntimeApiState>) -> Json<Value> {
338 // `rss_bytes` reads /proc on Linux and `current_exe` hits the filesystem;
339 // both are blocking, and this route is polled for live health.
340 let (executable, rss) =
341 tokio::task::spawn_blocking(|| (std::env::current_exe().ok(), rss_bytes()))
342 .await
343 .unwrap_or((None, None));
344 let (started_at, uptime_secs) = match SERVER_STARTED.get() {
345 Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())),
346 None => (None, None),
347 };
348 Json(json!({
349 "pid": std::process::id(),
350 "version": env!("CARGO_PKG_VERSION"),
351 "commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"),
352 "started_at": started_at,
353 "uptime_seconds": uptime_secs,
354 "executable": executable,
355 "rss_bytes": rss,
356 }))
357 }
358
358 lines RUST