Skip to main content

kaish_kernel/vfs/
jobfs.rs

1//! JobFs — Virtual filesystem for job observability.
2//!
3//! Provides `/proc`-like access to background job state:
4//!
5//! ```text
6//! /v/jobs/
7//! └── {job_id}/
8//!     ├── status   ← "running" | "stopped" | "done:0" | "killed:N" | "failed:N"
9//!     ├── command  ← the original command string
10//!     ├── stdout   ← the job's stdout so far, live while it runs
11//!     └── stderr   ← the job's stderr so far, live while it runs
12//! ```
13//!
14//! This is a read-only, synthesized filesystem. Content is generated from
15//! the JobManager on each read.
16//!
17//! **`stdout`/`stderr` are live.** GH #240 removed both nodes because they
18//! filled once, at job completion, while four docs promised a live stream.
19//! They are back on the terms the docs claimed: an external command running
20//! for the job tees each 8 KiB chunk into the job's stream as the child emits
21//! it, so `cat /v/jobs/1/stdout` on a running `cargo build &` reports the
22//! build's progress. Read `Job::stdout_stream` for exactly which bytes reach
23//! them — in particular, a builtin has no byte stream to tee, so a
24//! builtin-only job's output lands in one write when the job finishes.
25//!
26//! Each node is a 10 MB ring (`DEFAULT_STREAM_MAX_SIZE`) that evicts its
27//! oldest bytes rather than growing without bound. A job that outruns the
28//! ring loses its head, not its tail; redirect to a file
29//! (`cmd > /tmp/out &`) when the whole output matters.
30
31use async_trait::async_trait;
32use std::io;
33use std::path::Path;
34use std::sync::Arc;
35
36use super::{DirEntry, DirEntryKind, Filesystem};
37use crate::scheduler::{JobId, JobManager};
38
39/// Virtual filesystem providing job observability.
40///
41/// Mounted at `/v/jobs`, this filesystem synthesizes content from the JobManager:
42/// - List root to see all job IDs as directories
43/// - Read `{id}/status` for job status ("running", "stopped", "done:0", "killed:N", "failed:N")
44/// - Read `{id}/command` for the original command string
45/// - Read `{id}/stdout` / `{id}/stderr` for the job's output so far — live
46pub struct JobFs {
47    jobs: Arc<JobManager>,
48}
49
50impl JobFs {
51    /// Create a new JobFs backed by the given JobManager.
52    pub fn new(jobs: Arc<JobManager>) -> Self {
53        Self { jobs }
54    }
55
56    /// Parse a path into job ID and file name.
57    ///
58    /// Expected formats:
59    /// - "" or "/" → root (list jobs)
60    /// - "{id}" → job directory
61    /// - "{id}/{file}" → specific file (status, command, stdout, stderr)
62    fn parse_path(path: &Path) -> Option<(Option<JobId>, Option<&str>)> {
63        let path_str = path.to_str()?;
64        let path_str = path_str.trim_start_matches('/');
65
66        if path_str.is_empty() {
67            return Some((None, None)); // Root
68        }
69
70        let parts: Vec<&str> = path_str.split('/').collect();
71
72        match parts.as_slice() {
73            [id_str] => {
74                // Just job ID
75                let id: u64 = id_str.parse().ok()?;
76                Some((Some(JobId(id)), None))
77            }
78            [id_str, file] => {
79                // Job ID and file
80                let id: u64 = id_str.parse().ok()?;
81                Some((Some(JobId(id)), Some(*file)))
82            }
83            _ => None,
84        }
85    }
86}
87
88impl std::fmt::Debug for JobFs {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("JobFs").finish()
91    }
92}
93
94#[async_trait]
95impl Filesystem for JobFs {
96    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
97        let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
98            io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
99        })?;
100
101        let job_id = job_id.ok_or_else(|| {
102            io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
103        })?;
104
105        let file = file.ok_or_else(|| {
106            io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
107        })?;
108
109        // Check job exists
110        if !self.jobs.exists(job_id).await {
111            return Err(io::Error::new(
112                io::ErrorKind::NotFound,
113                format!("job {} not found", job_id),
114            ));
115        }
116
117        match file {
118            "status" => {
119                let status = self
120                    .jobs
121                    .get_status_string(job_id)
122                    .await
123                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
124                Ok(format!("{}\n", status).into_bytes())
125            }
126            "command" => {
127                let command = self
128                    .jobs
129                    .get_command(job_id)
130                    .await
131                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
132                Ok(format!("{}\n", command).into_bytes())
133            }
134            "stdout" => {
135                // Raw bytes, no trailing newline added: this is the child's
136                // output verbatim, and a synthesized "\n" would corrupt
137                // binary output and lie about text output that has none.
138                self.jobs
139                    .read_stdout(job_id)
140                    .await
141                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))
142            }
143            "stderr" => {
144                self.jobs
145                    .read_stderr(job_id)
146                    .await
147                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))
148            }
149            _ => Err(io::Error::new(
150                io::ErrorKind::NotFound,
151                format!("unknown file: {}", file),
152            )),
153        }
154    }
155
156    async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
157        Err(io::Error::new(
158            io::ErrorKind::PermissionDenied,
159            "jobfs is read-only",
160        ))
161    }
162
163    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
164        let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
165            io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
166        })?;
167
168        // Can't list a file
169        if file.is_some() {
170            return Err(io::Error::new(
171                io::ErrorKind::NotADirectory,
172                "not a directory",
173            ));
174        }
175
176        match job_id {
177            None => {
178                // List root: all job IDs as directories
179                let job_ids = self.jobs.list_ids().await;
180                let entries = job_ids
181                    .into_iter()
182                    .map(|id| DirEntry {
183                        name: id.0.to_string(),
184                        kind: DirEntryKind::Directory,
185                        modified: None,
186                        permissions: None,
187                        size: 0,
188                        symlink_target: None,
189                    })
190                    .collect();
191                Ok(entries)
192            }
193            Some(id) => {
194                // List job directory: status, command, stdout, stderr
195                if !self.jobs.exists(id).await {
196                    return Err(io::Error::new(
197                        io::ErrorKind::NotFound,
198                        format!("job {} not found", id),
199                    ));
200                }
201
202                Ok(vec![
203                    DirEntry {
204                        name: "status".to_string(),
205                        kind: DirEntryKind::File,
206                        modified: None,
207                        permissions: None,
208                        size: 0,
209                        symlink_target: None,
210                    },
211                    DirEntry {
212                        name: "command".to_string(),
213                        kind: DirEntryKind::File,
214                        modified: None,
215                        permissions: None,
216                        size: 0,
217                        symlink_target: None,
218                    },
219                    DirEntry {
220                        name: "stdout".to_string(),
221                        kind: DirEntryKind::File,
222                        modified: None,
223                        permissions: None,
224                        // Reported as 0 like every other node here: the real
225                        // size changes between this listing and the read that
226                        // follows it, and a stale number is worse than none.
227                        size: 0,
228                        symlink_target: None,
229                    },
230                    DirEntry {
231                        name: "stderr".to_string(),
232                        kind: DirEntryKind::File,
233                        modified: None,
234                        permissions: None,
235                        size: 0,
236                        symlink_target: None,
237                    },
238                ])
239            }
240        }
241    }
242
243    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
244        let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
245            io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
246        })?;
247
248        let name = path
249            .file_name()
250            .map(|n| n.to_string_lossy().into_owned())
251            .unwrap_or_else(|| "/".to_string());
252
253        match (job_id, file) {
254            (None, None) => {
255                // Root directory
256                Ok(DirEntry::directory(name))
257            }
258            (Some(id), None) => {
259                // Job directory
260                if !self.jobs.exists(id).await {
261                    return Err(io::Error::new(
262                        io::ErrorKind::NotFound,
263                        format!("job {} not found", id),
264                    ));
265                }
266                Ok(DirEntry::directory(name))
267            }
268            (Some(id), Some(file)) => {
269                // File inside job directory
270                if !self.jobs.exists(id).await {
271                    return Err(io::Error::new(
272                        io::ErrorKind::NotFound,
273                        format!("job {} not found", id),
274                    ));
275                }
276
277                // Validate file name
278                if !["status", "command", "stdout", "stderr"].contains(&file) {
279                    return Err(io::Error::new(
280                        io::ErrorKind::NotFound,
281                        format!("unknown file: {}", file),
282                    ));
283                }
284
285                Ok(DirEntry::file(name, 0))
286            }
287            (None, Some(_)) => {
288                // Invalid: file at root level
289                Err(io::Error::new(
290                    io::ErrorKind::NotFound,
291                    "invalid path",
292                ))
293            }
294        }
295    }
296
297    async fn mkdir(&self, _path: &Path) -> io::Result<()> {
298        Err(io::Error::new(
299            io::ErrorKind::PermissionDenied,
300            "jobfs is read-only",
301        ))
302    }
303
304    async fn remove(&self, _path: &Path) -> io::Result<()> {
305        Err(io::Error::new(
306            io::ErrorKind::PermissionDenied,
307            "jobfs is read-only",
308        ))
309    }
310
311    fn read_only(&self) -> bool {
312        true
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::interpreter::ExecResult;
320    use tokio::sync::oneshot;
321
322    async fn make_job_manager_with_job() -> (Arc<JobManager>, JobId) {
323        let manager = Arc::new(JobManager::new());
324
325        // Create channel for job completion
326        let (tx, rx) = oneshot::channel();
327
328        // Register job
329        let id = manager.register("echo test".to_string(), rx).await;
330
331        // Send result (job completes)
332        let _ = tx.send(ExecResult::success("done"));
333
334        (manager, id)
335    }
336
337    #[tokio::test]
338    async fn test_list_root_empty() {
339        let manager = Arc::new(JobManager::new());
340        let fs = JobFs::new(manager);
341
342        let entries = fs.list(Path::new("")).await.unwrap();
343        assert!(entries.is_empty());
344    }
345
346    #[tokio::test]
347    async fn test_list_root_with_jobs() {
348        let (manager, id) = make_job_manager_with_job().await;
349        let fs = JobFs::new(manager);
350
351        let entries = fs.list(Path::new("")).await.unwrap();
352        assert_eq!(entries.len(), 1);
353        assert_eq!(entries[0].name, id.0.to_string());
354        assert_eq!(entries[0].kind, DirEntryKind::Directory);
355    }
356
357    #[tokio::test]
358    async fn test_list_job_directory() {
359        let (manager, id) = make_job_manager_with_job().await;
360        let fs = JobFs::new(manager);
361
362        let path = format!("{}", id);
363        let entries = fs.list(Path::new(&path)).await.unwrap();
364
365        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
366        assert!(names.contains(&&"stdout".to_string()), "stdout node is back, and live");
367        assert!(names.contains(&&"stderr".to_string()), "stderr node is back, and live");
368        assert!(names.contains(&&"status".to_string()));
369        assert!(names.contains(&&"command".to_string()));
370        assert!(!names.contains(&&"approval".to_string()), "the approval node went with the ledger");
371    }
372
373    #[tokio::test]
374    async fn test_read_status_running() {
375        let manager = Arc::new(JobManager::new());
376
377        // Create a job that won't complete
378        let (_tx, rx) = oneshot::channel();
379        let id = manager.register("sleep 100".to_string(), rx).await;
380
381        let fs = JobFs::new(manager);
382
383        let path = format!("{}/status", id);
384        let data = fs.read(Path::new(&path)).await.unwrap();
385        assert_eq!(String::from_utf8_lossy(&data), "running\n");
386    }
387
388    #[tokio::test]
389    async fn test_read_status_done() {
390        let (manager, id) = make_job_manager_with_job().await;
391
392        // Wait for job to complete
393        manager.wait(id).await;
394
395        let fs = JobFs::new(manager);
396
397        let path = format!("{}/status", id);
398        let data = fs.read(Path::new(&path)).await.unwrap();
399        assert_eq!(String::from_utf8_lossy(&data), "done:0\n");
400    }
401
402    #[tokio::test]
403    async fn test_read_command() {
404        let (manager, id) = make_job_manager_with_job().await;
405        let fs = JobFs::new(manager);
406
407        let path = format!("{}/command", id);
408        let data = fs.read(Path::new(&path)).await.unwrap();
409        assert_eq!(String::from_utf8_lossy(&data), "echo test\n");
410    }
411
412    #[tokio::test]
413    async fn test_stat_root() {
414        let manager = Arc::new(JobManager::new());
415        let fs = JobFs::new(manager);
416
417        let entry = fs.stat(Path::new("")).await.unwrap();
418        assert_eq!(entry.kind, DirEntryKind::Directory);
419    }
420
421    #[tokio::test]
422    async fn test_stat_job_dir() {
423        let (manager, id) = make_job_manager_with_job().await;
424        let fs = JobFs::new(manager);
425
426        let path = format!("{}", id);
427        let entry = fs.stat(Path::new(&path)).await.unwrap();
428        assert_eq!(entry.kind, DirEntryKind::Directory);
429    }
430
431    #[tokio::test]
432    async fn test_stat_file() {
433        let (manager, id) = make_job_manager_with_job().await;
434        let fs = JobFs::new(manager);
435
436        let path = format!("{}/status", id);
437        let entry = fs.stat(Path::new(&path)).await.unwrap();
438        assert_eq!(entry.kind, DirEntryKind::File);
439    }
440
441    #[tokio::test]
442    async fn test_stat_stdout() {
443        let (manager, id) = make_job_manager_with_job().await;
444        let fs = JobFs::new(manager);
445
446        let path = format!("{}/stdout", id);
447        let entry = fs.stat(Path::new(&path)).await.unwrap();
448        assert_eq!(entry.kind, DirEntryKind::File);
449    }
450
451    /// A job that has written nothing reads as an empty node, not an error —
452    /// "nothing yet" and "no such job" stay distinguishable.
453    #[tokio::test]
454    async fn test_read_stdout_before_anything_is_written() {
455        let (manager, id) = make_job_manager_with_job().await;
456        let fs = JobFs::new(manager);
457
458        let path = format!("{}/stdout", id);
459        assert_eq!(fs.read(Path::new(&path)).await.unwrap(), Vec::<u8>::new());
460    }
461
462    /// The node reports whatever is in the stream at the moment of the read —
463    /// no waiting for the job, no synthesized trailing newline.
464    #[tokio::test]
465    async fn test_read_stdout_reflects_the_live_stream() {
466        let (manager, id) = make_job_manager_with_job().await;
467        let streams = manager.streams(id).await.unwrap();
468        streams.stdout.write(b"partial").await;
469
470        let fs = JobFs::new(manager);
471        let path = format!("{}/stdout", id);
472        assert_eq!(fs.read(Path::new(&path)).await.unwrap(), b"partial".to_vec());
473    }
474
475    #[tokio::test]
476    async fn test_stat_unknown_file_is_still_not_found() {
477        let (manager, id) = make_job_manager_with_job().await;
478        let fs = JobFs::new(manager);
479
480        let path = format!("{}/nope", id);
481        let result = fs.stat(Path::new(&path)).await;
482        assert!(result.is_err());
483        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
484    }
485
486    #[tokio::test]
487    async fn test_read_only() {
488        let manager = Arc::new(JobManager::new());
489        let fs = JobFs::new(manager);
490
491        assert!(fs.read_only());
492
493        let write_result = fs.write(Path::new("1/status"), b"data").await;
494        assert!(write_result.is_err());
495
496        let mkdir_result = fs.mkdir(Path::new("1")).await;
497        assert!(mkdir_result.is_err());
498
499        let remove_result = fs.remove(Path::new("1")).await;
500        assert!(remove_result.is_err());
501    }
502
503    #[tokio::test]
504    async fn test_nonexistent_job() {
505        let manager = Arc::new(JobManager::new());
506        let fs = JobFs::new(manager);
507
508        let result = fs.read(Path::new("999/status")).await;
509        assert!(result.is_err());
510        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
511    }
512
513    #[tokio::test]
514    async fn test_unknown_file() {
515        let (manager, id) = make_job_manager_with_job().await;
516        let fs = JobFs::new(manager);
517
518        let path = format!("{}/unknown", id);
519        let result = fs.read(Path::new(&path)).await;
520        assert!(result.is_err());
521        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
522    }
523
524    #[tokio::test]
525    async fn test_read_directory_error() {
526        let (manager, id) = make_job_manager_with_job().await;
527        let fs = JobFs::new(manager);
528
529        let path = format!("{}", id);
530        let result = fs.read(Path::new(&path)).await;
531        assert!(result.is_err());
532        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::IsADirectory);
533    }
534}