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//!     ├── stdout   ← live output stream (ring buffer snapshot)
9//!     ├── stderr   ← live error stream
10//!     ├── status   ← "running" | "done:0" | "failed:1"
11//!     └── command  ← the original command string
12//! ```
13//!
14//! This is a read-only, synthesized filesystem. Content is generated from
15//! the JobManager on each read.
16
17use async_trait::async_trait;
18use std::io;
19use std::path::Path;
20use std::sync::Arc;
21
22use super::{DirEntry, DirEntryKind, Filesystem};
23use crate::scheduler::{JobId, JobManager};
24
25/// Virtual filesystem providing job observability.
26///
27/// Mounted at `/v/jobs`, this filesystem synthesizes content from the JobManager:
28/// - List root to see all job IDs as directories
29/// - Read `{id}/stdout` for live stdout output
30/// - Read `{id}/stderr` for live stderr output
31/// - Read `{id}/status` for job status ("running", "done:0", "failed:N")
32/// - Read `{id}/command` for the original command string
33pub struct JobFs {
34    jobs: Arc<JobManager>,
35}
36
37impl JobFs {
38    /// Create a new JobFs backed by the given JobManager.
39    pub fn new(jobs: Arc<JobManager>) -> Self {
40        Self { jobs }
41    }
42
43    /// Parse a path into job ID and file name.
44    ///
45    /// Expected formats:
46    /// - "" or "/" → root (list jobs)
47    /// - "{id}" → job directory
48    /// - "{id}/{file}" → specific file (stdout, stderr, status, command)
49    fn parse_path(path: &Path) -> Option<(Option<JobId>, Option<&str>)> {
50        let path_str = path.to_str()?;
51        let path_str = path_str.trim_start_matches('/');
52
53        if path_str.is_empty() {
54            return Some((None, None)); // Root
55        }
56
57        let parts: Vec<&str> = path_str.split('/').collect();
58
59        match parts.as_slice() {
60            [id_str] => {
61                // Just job ID
62                let id: u64 = id_str.parse().ok()?;
63                Some((Some(JobId(id)), None))
64            }
65            [id_str, file] => {
66                // Job ID and file
67                let id: u64 = id_str.parse().ok()?;
68                Some((Some(JobId(id)), Some(*file)))
69            }
70            _ => None,
71        }
72    }
73}
74
75impl std::fmt::Debug for JobFs {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("JobFs").finish()
78    }
79}
80
81#[async_trait]
82impl Filesystem for JobFs {
83    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
84        let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
85            io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
86        })?;
87
88        let job_id = job_id.ok_or_else(|| {
89            io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
90        })?;
91
92        let file = file.ok_or_else(|| {
93            io::Error::new(io::ErrorKind::IsADirectory, "cannot read directory")
94        })?;
95
96        // Check job exists
97        if !self.jobs.exists(job_id).await {
98            return Err(io::Error::new(
99                io::ErrorKind::NotFound,
100                format!("job {} not found", job_id),
101            ));
102        }
103
104        match file {
105            "stdout" => {
106                // Return stream content, or empty if no stream attached
107                let content = self.jobs.read_stdout(job_id).await.unwrap_or_default();
108                Ok(content)
109            }
110            "stderr" => {
111                let content = self.jobs.read_stderr(job_id).await.unwrap_or_default();
112                Ok(content)
113            }
114            "status" => {
115                let status = self
116                    .jobs
117                    .get_status_string(job_id)
118                    .await
119                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
120                Ok(format!("{}\n", status).into_bytes())
121            }
122            "command" => {
123                let command = self
124                    .jobs
125                    .get_command(job_id)
126                    .await
127                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "job not found"))?;
128                Ok(format!("{}\n", command).into_bytes())
129            }
130            "latch" => {
131                // A gated job's pending confirmation-latch request as JSON, so
132                // a VFS consumer can read the nonce and fulfill a backgrounded
133                // gate (GH #96). Empty body when the job isn't latched — a
134                // consumer reads, then parses only if non-empty.
135                match self.jobs.get_latch(job_id).await {
136                    Some(latch) => {
137                        let json = serde_json::to_string_pretty(&latch)
138                            .map_err(|e| io::Error::other(format!("latch serialize: {e}")))?;
139                        Ok(format!("{json}\n").into_bytes())
140                    }
141                    None => Ok(Vec::new()),
142                }
143            }
144            _ => Err(io::Error::new(
145                io::ErrorKind::NotFound,
146                format!("unknown file: {}", file),
147            )),
148        }
149    }
150
151    async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
152        Err(io::Error::new(
153            io::ErrorKind::PermissionDenied,
154            "jobfs is read-only",
155        ))
156    }
157
158    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
159        let (job_id, file) = Self::parse_path(path).ok_or_else(|| {
160            io::Error::new(io::ErrorKind::InvalidInput, "invalid job path")
161        })?;
162
163        // Can't list a file
164        if file.is_some() {
165            return Err(io::Error::new(
166                io::ErrorKind::NotADirectory,
167                "not a directory",
168            ));
169        }
170
171        match job_id {
172            None => {
173                // List root: all job IDs as directories
174                let job_ids = self.jobs.list_ids().await;
175                let entries = job_ids
176                    .into_iter()
177                    .map(|id| DirEntry {
178                        name: id.0.to_string(),
179                        kind: DirEntryKind::Directory,
180                        modified: None,
181                        permissions: None,
182                        size: 0,
183                        symlink_target: None,
184                    })
185                    .collect();
186                Ok(entries)
187            }
188            Some(id) => {
189                // List job directory: stdout, stderr, status, command
190                if !self.jobs.exists(id).await {
191                    return Err(io::Error::new(
192                        io::ErrorKind::NotFound,
193                        format!("job {} not found", id),
194                    ));
195                }
196
197                Ok(vec![
198                    DirEntry {
199                        name: "stdout".to_string(),
200                        kind: DirEntryKind::File,
201                        modified: None,
202                        permissions: None,
203                        size: 0, // Dynamic content
204                        symlink_target: None,
205                    },
206                    DirEntry {
207                        name: "stderr".to_string(),
208                        kind: DirEntryKind::File,
209                        modified: None,
210                        permissions: None,
211                        size: 0,
212                        symlink_target: None,
213                    },
214                    DirEntry {
215                        name: "status".to_string(),
216                        kind: DirEntryKind::File,
217                        modified: None,
218                        permissions: None,
219                        size: 0,
220                        symlink_target: None,
221                    },
222                    DirEntry {
223                        name: "command".to_string(),
224                        kind: DirEntryKind::File,
225                        modified: None,
226                        permissions: None,
227                        size: 0,
228                        symlink_target: None,
229                    },
230                    DirEntry {
231                        name: "latch".to_string(),
232                        kind: DirEntryKind::File,
233                        modified: None,
234                        permissions: None,
235                        size: 0, // JSON when gated, empty otherwise
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 !["stdout", "stderr", "status", "command", "latch"].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 crate::scheduler::BoundedStream;
321    use tokio::sync::oneshot;
322
323    async fn make_job_manager_with_job() -> (Arc<JobManager>, JobId) {
324        let manager = Arc::new(JobManager::new());
325
326        // Create streams
327        let stdout = Arc::new(BoundedStream::new(1024));
328        let stderr = Arc::new(BoundedStream::new(1024));
329
330        // Write some output
331        stdout.write(b"hello from stdout\n").await;
332        stderr.write(b"error message\n").await;
333
334        // Create channel for job completion
335        let (tx, rx) = oneshot::channel();
336
337        // Register job
338        let id = manager
339            .register_with_streams("echo test".to_string(), rx, stdout, stderr)
340            .await;
341
342        // Send result (job completes)
343        let _ = tx.send(ExecResult::success("done"));
344
345        (manager, id)
346    }
347
348    #[tokio::test]
349    async fn test_list_root_empty() {
350        let manager = Arc::new(JobManager::new());
351        let fs = JobFs::new(manager);
352
353        let entries = fs.list(Path::new("")).await.unwrap();
354        assert!(entries.is_empty());
355    }
356
357    #[tokio::test]
358    async fn test_list_root_with_jobs() {
359        let (manager, id) = make_job_manager_with_job().await;
360        let fs = JobFs::new(manager);
361
362        let entries = fs.list(Path::new("")).await.unwrap();
363        assert_eq!(entries.len(), 1);
364        assert_eq!(entries[0].name, id.0.to_string());
365        assert_eq!(entries[0].kind, DirEntryKind::Directory);
366    }
367
368    #[tokio::test]
369    async fn test_list_job_directory() {
370        let (manager, id) = make_job_manager_with_job().await;
371        let fs = JobFs::new(manager);
372
373        let path = format!("{}", id);
374        let entries = fs.list(Path::new(&path)).await.unwrap();
375
376        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
377        assert!(names.contains(&&"stdout".to_string()));
378        assert!(names.contains(&&"stderr".to_string()));
379        assert!(names.contains(&&"status".to_string()));
380        assert!(names.contains(&&"command".to_string()));
381    }
382
383    #[tokio::test]
384    async fn test_read_stdout() {
385        let (manager, id) = make_job_manager_with_job().await;
386        let fs = JobFs::new(manager);
387
388        let path = format!("{}/stdout", id);
389        let data = fs.read(Path::new(&path)).await.unwrap();
390        assert_eq!(data, b"hello from stdout\n");
391    }
392
393    #[tokio::test]
394    async fn test_read_stderr() {
395        let (manager, id) = make_job_manager_with_job().await;
396        let fs = JobFs::new(manager);
397
398        let path = format!("{}/stderr", id);
399        let data = fs.read(Path::new(&path)).await.unwrap();
400        assert_eq!(data, b"error message\n");
401    }
402
403    #[tokio::test]
404    async fn test_read_status_running() {
405        let manager = Arc::new(JobManager::new());
406
407        // Create a job that won't complete
408        let stdout = Arc::new(BoundedStream::new(1024));
409        let stderr = Arc::new(BoundedStream::new(1024));
410        let (_tx, rx) = oneshot::channel();
411        let id = manager
412            .register_with_streams("sleep 100".to_string(), rx, stdout, stderr)
413            .await;
414
415        let fs = JobFs::new(manager);
416
417        let path = format!("{}/status", id);
418        let data = fs.read(Path::new(&path)).await.unwrap();
419        assert_eq!(String::from_utf8_lossy(&data), "running\n");
420    }
421
422    #[tokio::test]
423    async fn test_read_status_done() {
424        let (manager, id) = make_job_manager_with_job().await;
425
426        // Wait for job to complete
427        manager.wait(id).await;
428
429        let fs = JobFs::new(manager);
430
431        let path = format!("{}/status", id);
432        let data = fs.read(Path::new(&path)).await.unwrap();
433        assert_eq!(String::from_utf8_lossy(&data), "done:0\n");
434    }
435
436    #[tokio::test]
437    async fn test_read_command() {
438        let (manager, id) = make_job_manager_with_job().await;
439        let fs = JobFs::new(manager);
440
441        let path = format!("{}/command", id);
442        let data = fs.read(Path::new(&path)).await.unwrap();
443        assert_eq!(String::from_utf8_lossy(&data), "echo test\n");
444    }
445
446    #[tokio::test]
447    async fn test_stat_root() {
448        let manager = Arc::new(JobManager::new());
449        let fs = JobFs::new(manager);
450
451        let entry = fs.stat(Path::new("")).await.unwrap();
452        assert_eq!(entry.kind, DirEntryKind::Directory);
453    }
454
455    #[tokio::test]
456    async fn test_stat_job_dir() {
457        let (manager, id) = make_job_manager_with_job().await;
458        let fs = JobFs::new(manager);
459
460        let path = format!("{}", id);
461        let entry = fs.stat(Path::new(&path)).await.unwrap();
462        assert_eq!(entry.kind, DirEntryKind::Directory);
463    }
464
465    #[tokio::test]
466    async fn test_stat_file() {
467        let (manager, id) = make_job_manager_with_job().await;
468        let fs = JobFs::new(manager);
469
470        let path = format!("{}/stdout", id);
471        let entry = fs.stat(Path::new(&path)).await.unwrap();
472        assert_eq!(entry.kind, DirEntryKind::File);
473    }
474
475    #[tokio::test]
476    async fn test_read_only() {
477        let manager = Arc::new(JobManager::new());
478        let fs = JobFs::new(manager);
479
480        assert!(fs.read_only());
481
482        let write_result = fs.write(Path::new("1/stdout"), b"data").await;
483        assert!(write_result.is_err());
484
485        let mkdir_result = fs.mkdir(Path::new("1")).await;
486        assert!(mkdir_result.is_err());
487
488        let remove_result = fs.remove(Path::new("1")).await;
489        assert!(remove_result.is_err());
490    }
491
492    #[tokio::test]
493    async fn test_nonexistent_job() {
494        let manager = Arc::new(JobManager::new());
495        let fs = JobFs::new(manager);
496
497        let result = fs.read(Path::new("999/stdout")).await;
498        assert!(result.is_err());
499        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
500    }
501
502    #[tokio::test]
503    async fn test_unknown_file() {
504        let (manager, id) = make_job_manager_with_job().await;
505        let fs = JobFs::new(manager);
506
507        let path = format!("{}/unknown", id);
508        let result = fs.read(Path::new(&path)).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_read_directory_error() {
515        let (manager, id) = make_job_manager_with_job().await;
516        let fs = JobFs::new(manager);
517
518        let path = format!("{}", id);
519        let result = fs.read(Path::new(&path)).await;
520        assert!(result.is_err());
521        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::IsADirectory);
522    }
523}