| 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 |