Skip to main content

agent_exec/
jobstore.rs

1//! Job directory management for agent-exec v0.1.
2//!
3//! Resolution order for the jobs root:
4//!   1. `--root` CLI flag
5//!   2. `AGENT_EXEC_ROOT` environment variable
6//!   3. `$XDG_DATA_HOME/agent-exec/jobs`
7//!   4. `~/.local/share/agent-exec/jobs`
8
9use anyhow::{Context, Result};
10use directories::BaseDirs;
11use std::path::PathBuf;
12
13use crate::schema::{JobMeta, JobState, JobStatus};
14
15/// Sentinel error type to distinguish "job not found" from other I/O errors.
16/// Used by callers to emit `error.code = "job_not_found"` instead of `internal_error`.
17#[derive(Debug)]
18pub struct JobNotFound(pub String);
19
20impl std::fmt::Display for JobNotFound {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "job not found: {}", self.0)
23    }
24}
25
26impl std::error::Error for JobNotFound {}
27
28/// Sentinel error type when a job ID prefix matches multiple job directories.
29/// Used by callers to emit `error.code = "ambiguous_job_id"` instead of `internal_error`.
30#[derive(Debug)]
31pub struct AmbiguousJobId {
32    pub prefix: String,
33    pub candidates: Vec<String>,
34}
35
36impl std::fmt::Display for AmbiguousJobId {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "ambiguous job ID prefix '{}': matches ", self.prefix)?;
39        if self.candidates.len() <= 5 {
40            write!(f, "{}", self.candidates.join(", "))
41        } else {
42            write!(
43                f,
44                "{}, ... and {} more",
45                self.candidates[..5].join(", "),
46                self.candidates.len() - 5
47            )
48        }
49    }
50}
51
52impl std::error::Error for AmbiguousJobId {}
53
54/// Sentinel error type for invalid job state transitions.
55/// Used by callers to emit `error.code = "invalid_state"` instead of `internal_error`.
56#[derive(Debug)]
57pub struct InvalidJobState(pub String);
58
59impl std::fmt::Display for InvalidJobState {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "invalid job state: {}", self.0)
62    }
63}
64
65impl std::error::Error for InvalidJobState {}
66
67/// Resolve the jobs root directory following the priority chain.
68pub fn resolve_root(cli_root: Option<&str>) -> PathBuf {
69    // 1. CLI flag
70    if let Some(root) = cli_root {
71        return PathBuf::from(root);
72    }
73
74    // 2. Environment variable
75    if let Ok(root) = std::env::var("AGENT_EXEC_ROOT")
76        && !root.is_empty()
77    {
78        return PathBuf::from(root);
79    }
80
81    // 3. XDG_DATA_HOME
82    if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
83        && !xdg.is_empty()
84    {
85        return PathBuf::from(xdg).join("agent-exec").join("jobs");
86    }
87
88    // 4. Default: ~/.local/share/agent-exec/jobs
89    //    (On Windows use data_local_dir() as base)
90    if let Some(base_dirs) = BaseDirs::new() {
91        #[cfg(windows)]
92        let base = base_dirs.data_local_dir().to_path_buf();
93        #[cfg(not(windows))]
94        let base = base_dirs.home_dir().join(".local").join("share");
95        return base.join("agent-exec").join("jobs");
96    }
97
98    // Fallback if directories crate returns None
99    PathBuf::from("~/.local/share/agent-exec/jobs")
100}
101
102/// Metrics returned by [`JobDir::read_tail_metrics`].
103///
104/// Bundles the tail content together with the byte counts used in the
105/// `run` snapshot and `tail` JSON responses, so that both callers share
106/// the same calculation logic.
107pub struct TailMetrics {
108    /// The tail text (lossy UTF-8, last N lines / max_bytes).
109    pub tail: String,
110    /// Whether the content was truncated by bytes or lines constraints.
111    pub truncated: bool,
112    /// Total file size in bytes (0 if the file does not exist).
113    pub observed_bytes: u64,
114    /// Number of bytes included in `tail`.
115    pub included_bytes: u64,
116}
117
118/// Handle to a specific job's directory.
119#[derive(Debug)]
120pub struct JobDir {
121    pub path: PathBuf,
122    pub job_id: String,
123}
124
125impl JobDir {
126    /// Open an existing job directory by ID or unambiguous prefix.
127    ///
128    /// Resolution order:
129    /// 1. Exact match: if `root/<job_id>` exists, return it immediately (no scan).
130    /// 2. Prefix scan: scan `root/` for directories whose name starts with `job_id`.
131    ///    - 0 matches → `Err(JobNotFound)`
132    ///    - 1 match   → resolve to that job
133    ///    - 2+ matches → `Err(AmbiguousJobId)`
134    pub fn open(root: &std::path::Path, job_id: &str) -> Result<Self> {
135        // Exact-match fast path: no directory scan needed.
136        let path = root.join(job_id);
137        if path.is_dir() {
138            return Ok(JobDir {
139                path,
140                job_id: job_id.to_string(),
141            });
142        }
143
144        // Prefix scan: collect all directories whose name starts with `job_id`.
145        let mut candidates: Vec<String> = std::fs::read_dir(root)
146            .into_iter()
147            .flatten()
148            .flatten()
149            .filter_map(|entry| {
150                let name = entry.file_name().to_string_lossy().into_owned();
151                if name.starts_with(job_id) && entry.path().is_dir() {
152                    Some(name)
153                } else {
154                    None
155                }
156            })
157            .collect();
158
159        match candidates.len() {
160            0 => Err(anyhow::Error::new(JobNotFound(job_id.to_string()))),
161            1 => {
162                let resolved = candidates.remove(0);
163                let path = root.join(&resolved);
164                Ok(JobDir {
165                    path,
166                    job_id: resolved,
167                })
168            }
169            _ => {
170                candidates.sort();
171                Err(anyhow::Error::new(AmbiguousJobId {
172                    prefix: job_id.to_string(),
173                    candidates,
174                }))
175            }
176        }
177    }
178
179    /// Create a new job directory and write `meta.json` atomically.
180    pub fn create(root: &std::path::Path, job_id: &str, meta: &JobMeta) -> Result<Self> {
181        let path = root.join(job_id);
182        std::fs::create_dir_all(&path)
183            .with_context(|| format!("create job dir {}", path.display()))?;
184
185        let job_dir = JobDir {
186            path,
187            job_id: job_id.to_string(),
188        };
189
190        job_dir.write_meta_atomic(meta)?;
191
192        Ok(job_dir)
193    }
194
195    pub fn meta_path(&self) -> PathBuf {
196        self.path.join("meta.json")
197    }
198    pub fn state_path(&self) -> PathBuf {
199        self.path.join("state.json")
200    }
201    pub fn stdout_path(&self) -> PathBuf {
202        self.path.join("stdout.log")
203    }
204    pub fn stderr_path(&self) -> PathBuf {
205        self.path.join("stderr.log")
206    }
207    pub fn full_log_path(&self) -> PathBuf {
208        self.path.join("full.log")
209    }
210    pub fn completion_event_path(&self) -> PathBuf {
211        self.path.join("completion_event.json")
212    }
213    pub fn notification_events_path(&self) -> PathBuf {
214        self.path.join("notification_events.ndjson")
215    }
216
217    /// Write `completion_event.json` atomically.
218    pub fn write_completion_event_atomic(
219        &self,
220        record: &crate::schema::CompletionEventRecord,
221    ) -> Result<()> {
222        let target = self.completion_event_path();
223        let contents = serde_json::to_string_pretty(record)?;
224        write_atomic(&self.path, &target, contents.as_bytes())?;
225        Ok(())
226    }
227
228    pub fn read_meta(&self) -> Result<JobMeta> {
229        let raw = std::fs::read(self.meta_path())?;
230        Ok(serde_json::from_slice(&raw)?)
231    }
232
233    pub fn read_state(&self) -> Result<JobState> {
234        let raw = std::fs::read(self.state_path())?;
235        Ok(serde_json::from_slice(&raw)?)
236    }
237
238    /// Write `meta.json` atomically: write to a temp file then rename.
239    pub fn write_meta_atomic(&self, meta: &JobMeta) -> Result<()> {
240        let target = self.meta_path();
241        let contents = serde_json::to_string_pretty(meta)?;
242        write_atomic(&self.path, &target, contents.as_bytes())?;
243        Ok(())
244    }
245
246    /// Write `state.json` atomically: write to a temp file then rename.
247    pub fn write_state(&self, state: &JobState) -> Result<()> {
248        let target = self.state_path();
249        let contents = serde_json::to_string_pretty(state)?;
250        write_atomic(&self.path, &target, contents.as_bytes())?;
251        Ok(())
252    }
253
254    /// Read the last `max_bytes` of a log file, returning lossy UTF-8.
255    pub fn tail_log(&self, filename: &str, tail_lines: u64, max_bytes: u64) -> String {
256        self.tail_log_with_truncated(filename, tail_lines, max_bytes)
257            .0
258    }
259
260    /// Read the last `max_bytes` of a log file, returning (content, truncated).
261    /// `truncated` is true when the content was cut by bytes or lines constraints.
262    pub fn tail_log_with_truncated(
263        &self,
264        filename: &str,
265        tail_lines: u64,
266        max_bytes: u64,
267    ) -> (String, bool) {
268        let path = self.path.join(filename);
269        let Ok(data) = std::fs::read(&path) else {
270            return (String::new(), false);
271        };
272
273        // Truncate to max_bytes from the end.
274        let byte_truncated = data.len() as u64 > max_bytes;
275        let start = if byte_truncated {
276            (data.len() as u64 - max_bytes) as usize
277        } else {
278            0
279        };
280        let slice = &data[start..];
281
282        // Lossy UTF-8 decode.
283        let text = String::from_utf8_lossy(slice);
284
285        // Keep only the last tail_lines.
286        if tail_lines == 0 {
287            return (text.into_owned(), byte_truncated);
288        }
289        let lines: Vec<&str> = text.lines().collect();
290        let skip = lines.len().saturating_sub(tail_lines as usize);
291        let line_truncated = skip > 0;
292        (lines[skip..].join("\n"), byte_truncated || line_truncated)
293    }
294
295    /// Read tail content and byte metrics for a single log file.
296    ///
297    /// Returns a [`TailMetrics`] that bundles the tail text, truncation flag,
298    /// observed file size, and included byte count.  Both `run`'s snapshot
299    /// generation and `tail`'s JSON generation use this helper so that the
300    /// metric calculation is defined in exactly one place.
301    ///
302    /// `encoding` is always `"utf-8-lossy"` (as required by the contract).
303    pub fn read_tail_metrics(
304        &self,
305        filename: &str,
306        tail_lines: u64,
307        max_bytes: u64,
308    ) -> TailMetrics {
309        let (tail, truncated) = self.tail_log_with_truncated(filename, tail_lines, max_bytes);
310        let included_bytes = tail.len() as u64;
311        let observed_bytes = std::fs::metadata(self.path.join(filename))
312            .map(|m| m.len())
313            .unwrap_or(0);
314        TailMetrics {
315            tail,
316            truncated,
317            observed_bytes,
318            included_bytes,
319        }
320    }
321
322    /// Write the initial JobState for a `created` (not-yet-started) job.
323    ///
324    /// The state is `created`, no process has been spawned, and `started_at` is absent.
325    pub fn init_state_created(&self) -> Result<JobState> {
326        let state = JobState {
327            job: crate::schema::JobStateJob {
328                id: self.job_id.clone(),
329                status: JobStatus::Created,
330                started_at: None,
331            },
332            result: crate::schema::JobStateResult {
333                exit_code: None,
334                signal: None,
335                duration_ms: None,
336            },
337            pid: None,
338            finished_at: None,
339            updated_at: crate::run::now_rfc3339_pub(),
340            windows_job_name: None,
341        };
342        self.write_state(&state)?;
343        Ok(state)
344    }
345
346    /// Write the initial JobState (running, supervisor PID) to disk.
347    ///
348    /// This is called by the `run` command immediately after the supervisor
349    /// process is spawned, so `pid` is the supervisor's PID. The child process
350    /// PID and, on Windows, the Job Object name are not yet known at this point.
351    ///
352    /// On Windows, the Job Object name is derived deterministically from the
353    /// job_id as `"AgentExec-{job_id}"`. This name is written immediately to
354    /// `state.json` so that callers reading state after `run` returns can
355    /// always find the Job Object identifier, without waiting for the supervisor
356    /// to perform its first `write_state` call. The supervisor will confirm the
357    /// same name (or update to `failed`) after it successfully assigns the child
358    /// process to the named Job Object.
359    pub fn init_state(&self, pid: u32, started_at: &str) -> Result<JobState> {
360        #[cfg(windows)]
361        let windows_job_name = Some(format!("AgentExec-{}", self.job_id));
362        #[cfg(not(windows))]
363        let windows_job_name: Option<String> = None;
364
365        let state = JobState {
366            job: crate::schema::JobStateJob {
367                id: self.job_id.clone(),
368                status: JobStatus::Running,
369                started_at: Some(started_at.to_string()),
370            },
371            result: crate::schema::JobStateResult {
372                exit_code: None,
373                signal: None,
374                duration_ms: None,
375            },
376            pid: Some(pid),
377            finished_at: None,
378            updated_at: crate::run::now_rfc3339_pub(),
379            windows_job_name,
380        };
381        self.write_state(&state)?;
382        Ok(state)
383    }
384}
385
386/// Write `contents` to `target` atomically by writing to a temp file in the
387/// same directory and then renaming. This prevents readers from observing a
388/// partially-written file.
389fn write_atomic(dir: &std::path::Path, target: &std::path::Path, contents: &[u8]) -> Result<()> {
390    use std::io::Write;
391
392    // Create a named temporary file in the same directory so that rename is
393    // always on the same filesystem (required for atomic rename on POSIX).
394    let mut tmp = tempfile::Builder::new()
395        .prefix(".tmp-")
396        .tempfile_in(dir)
397        .with_context(|| format!("create temp file in {}", dir.display()))?;
398
399    tmp.write_all(contents)
400        .with_context(|| format!("write temp file for {}", target.display()))?;
401
402    // Persist moves the temp file to the target path atomically.
403    tmp.persist(target)
404        .map_err(|e| e.error)
405        .with_context(|| format!("rename temp file to {}", target.display()))?;
406
407    Ok(())
408}
409
410// ---------- Unit tests ----------
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    /// Global mutex to serialize tests that mutate process-wide environment variables.
417    ///
418    /// Rust runs tests in parallel by default; any test that calls `set_var` /
419    /// `remove_var` must hold this lock for the duration of the test so that
420    /// other env-reading tests do not observe a half-mutated environment.
421    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
422
423    #[test]
424    fn resolve_root_cli_flag_wins() {
425        // CLI flag does not depend on environment variables; no lock needed.
426        let root = resolve_root(Some("/tmp/my-root"));
427        assert_eq!(root, PathBuf::from("/tmp/my-root"));
428    }
429
430    #[test]
431    fn resolve_root_env_var() {
432        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
433        // SAFETY: guarded by ENV_LOCK; no other env-mutating test runs concurrently.
434        unsafe {
435            std::env::set_var("AGENT_EXEC_ROOT", "/tmp/env-root");
436            // Also clear XDG to avoid interference.
437            std::env::remove_var("XDG_DATA_HOME");
438        }
439        // CLI flag is None, so env var should win.
440        let root = resolve_root(None);
441        // Restore.
442        unsafe {
443            std::env::remove_var("AGENT_EXEC_ROOT");
444        }
445        assert_eq!(root, PathBuf::from("/tmp/env-root"));
446    }
447
448    #[test]
449    fn resolve_root_xdg() {
450        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
451        // SAFETY: guarded by ENV_LOCK; no other env-mutating test runs concurrently.
452        unsafe {
453            std::env::remove_var("AGENT_EXEC_ROOT");
454            std::env::set_var("XDG_DATA_HOME", "/tmp/xdg");
455        }
456        let root = resolve_root(None);
457        unsafe {
458            std::env::remove_var("XDG_DATA_HOME");
459        }
460        assert_eq!(root, PathBuf::from("/tmp/xdg/agent-exec/jobs"));
461    }
462
463    #[test]
464    fn resolve_root_default_contains_agent_exec() {
465        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
466        // SAFETY: guarded by ENV_LOCK; no other env-mutating test runs concurrently.
467        unsafe {
468            std::env::remove_var("AGENT_EXEC_ROOT");
469            std::env::remove_var("XDG_DATA_HOME");
470        }
471        let root = resolve_root(None);
472        let root_str = root.to_string_lossy();
473        assert!(
474            root_str.contains("agent-exec"),
475            "expected agent-exec in path, got {root_str}"
476        );
477    }
478
479    // ---------- Job directory structure tests ----------
480
481    fn make_meta(job_id: &str, root: &std::path::Path) -> crate::schema::JobMeta {
482        crate::schema::JobMeta {
483            job: crate::schema::JobMetaJob {
484                id: job_id.to_string(),
485            },
486            schema_version: "0.1".to_string(),
487            command: vec!["echo".to_string(), "hello".to_string()],
488            created_at: "2024-01-01T00:00:00Z".to_string(),
489            root: root.display().to_string(),
490            env_keys: vec!["FOO".to_string()],
491            env_vars: vec![],
492            env_vars_runtime: vec![],
493            mask: vec![],
494            cwd: None,
495            notification: None,
496            tags: vec![],
497            inherit_env: true,
498            env_files: vec![],
499            timeout_ms: 0,
500            kill_after_ms: 0,
501            progress_every_ms: 0,
502            shell_wrapper: None,
503        }
504    }
505
506    /// Verify that job directory creation writes meta.json and the directory exists.
507    #[test]
508    fn job_dir_create_writes_meta_json() {
509        let tmp = tempfile::tempdir().unwrap();
510        let root = tmp.path();
511        let meta = make_meta("test-job-01", root);
512        let job_dir = JobDir::create(root, "test-job-01", &meta).unwrap();
513
514        // Directory must exist.
515        assert!(job_dir.path.is_dir(), "job directory was not created");
516
517        // meta.json must exist and be parseable.
518        assert!(job_dir.meta_path().exists(), "meta.json not found");
519        let loaded_meta = job_dir.read_meta().unwrap();
520        assert_eq!(loaded_meta.job_id(), "test-job-01");
521        assert_eq!(loaded_meta.command, vec!["echo", "hello"]);
522
523        // env_keys must contain key names only (not values).
524        assert_eq!(loaded_meta.env_keys, vec!["FOO"]);
525    }
526
527    /// Verify that meta.json does NOT contain env values (only keys).
528    #[test]
529    fn meta_json_env_keys_only_no_values() {
530        let tmp = tempfile::tempdir().unwrap();
531        let root = tmp.path();
532        let mut meta = make_meta("test-job-02", root);
533        // Simulate env_keys containing only key names (as would be extracted from KEY=VALUE pairs).
534        meta.env_keys = vec!["SECRET_KEY".to_string(), "API_TOKEN".to_string()];
535        let job_dir = JobDir::create(root, "test-job-02", &meta).unwrap();
536
537        // Read raw JSON to verify values are absent.
538        let raw = std::fs::read_to_string(job_dir.meta_path()).unwrap();
539        assert!(
540            !raw.contains("secret_value"),
541            "env value must not be stored in meta.json"
542        );
543        assert!(raw.contains("SECRET_KEY"), "env key must be stored");
544        assert!(raw.contains("API_TOKEN"), "env key must be stored");
545    }
546
547    /// Verify that state.json contains updated_at after write_state.
548    #[test]
549    fn state_json_contains_updated_at() {
550        let tmp = tempfile::tempdir().unwrap();
551        let root = tmp.path();
552        let meta = make_meta("test-job-03", root);
553        let job_dir = JobDir::create(root, "test-job-03", &meta).unwrap();
554
555        let state = crate::schema::JobState {
556            job: crate::schema::JobStateJob {
557                id: "test-job-03".to_string(),
558                status: crate::schema::JobStatus::Running,
559                started_at: Some("2024-01-01T00:00:00Z".to_string()),
560            },
561            result: crate::schema::JobStateResult {
562                exit_code: None,
563                signal: None,
564                duration_ms: None,
565            },
566            pid: Some(12345),
567            finished_at: None,
568            updated_at: "2024-01-01T00:00:01Z".to_string(),
569            windows_job_name: None,
570        };
571        job_dir.write_state(&state).unwrap();
572
573        // Read back and verify.
574        assert!(job_dir.state_path().exists(), "state.json not found");
575        let loaded = job_dir.read_state().unwrap();
576        assert_eq!(loaded.updated_at, "2024-01-01T00:00:01Z");
577        assert_eq!(loaded.job_id(), "test-job-03");
578
579        // Also verify the raw JSON contains the updated_at field.
580        let raw = std::fs::read_to_string(job_dir.state_path()).unwrap();
581        assert!(
582            raw.contains("updated_at"),
583            "updated_at field missing from state.json"
584        );
585    }
586
587    /// Verify that write_state uses atomic write (temp file + rename).
588    /// We verify this indirectly: the file must not be corrupted even if we
589    /// call write_state multiple times rapidly.
590    #[test]
591    fn state_json_atomic_write_no_corruption() {
592        let tmp = tempfile::tempdir().unwrap();
593        let root = tmp.path();
594        let meta = make_meta("test-job-04", root);
595        let job_dir = JobDir::create(root, "test-job-04", &meta).unwrap();
596
597        for i in 0..10 {
598            let state = crate::schema::JobState {
599                job: crate::schema::JobStateJob {
600                    id: "test-job-04".to_string(),
601                    status: crate::schema::JobStatus::Running,
602                    started_at: Some("2024-01-01T00:00:00Z".to_string()),
603                },
604                result: crate::schema::JobStateResult {
605                    exit_code: None,
606                    signal: None,
607                    duration_ms: None,
608                },
609                pid: Some(100 + i),
610                finished_at: None,
611                updated_at: format!("2024-01-01T00:00:{:02}Z", i),
612                windows_job_name: None,
613            };
614            job_dir.write_state(&state).unwrap();
615
616            // Each read must produce valid JSON (no corruption).
617            let loaded = job_dir.read_state().unwrap();
618            assert_eq!(
619                loaded.pid,
620                Some(100 + i),
621                "state corrupted at iteration {i}"
622            );
623        }
624    }
625
626    /// Verify that meta.json atomic write works correctly.
627    #[test]
628    fn meta_json_atomic_write() {
629        let tmp = tempfile::tempdir().unwrap();
630        let root = tmp.path();
631        let meta = make_meta("test-job-05", root);
632        let job_dir = JobDir::create(root, "test-job-05", &meta).unwrap();
633
634        // Re-write meta atomically.
635        let updated_meta = crate::schema::JobMeta {
636            job: crate::schema::JobMetaJob {
637                id: "test-job-05".to_string(),
638            },
639            schema_version: "0.1".to_string(),
640            command: vec!["ls".to_string()],
641            created_at: "2024-06-01T12:00:00Z".to_string(),
642            root: root.display().to_string(),
643            env_keys: vec!["PATH".to_string()],
644            env_vars: vec![],
645            env_vars_runtime: vec![],
646            mask: vec![],
647            cwd: None,
648            notification: None,
649            tags: vec![],
650            inherit_env: true,
651            env_files: vec![],
652            timeout_ms: 0,
653            kill_after_ms: 0,
654            progress_every_ms: 0,
655            shell_wrapper: None,
656        };
657        job_dir.write_meta_atomic(&updated_meta).unwrap();
658
659        let loaded = job_dir.read_meta().unwrap();
660        assert_eq!(loaded.command, vec!["ls"]);
661        assert_eq!(loaded.created_at, "2024-06-01T12:00:00Z");
662    }
663
664    /// On non-Windows platforms, `init_state` must write `windows_job_name: None`
665    /// (the field is omitted from JSON via `skip_serializing_if`).
666    /// On Windows, `init_state` must write the deterministic Job Object name
667    /// `"AgentExec-{job_id}"` so that `state.json` always contains the identifier
668    /// immediately after `run` returns, without waiting for the supervisor update.
669    #[test]
670    fn init_state_writes_deterministic_job_name_on_windows() {
671        let tmp = tempfile::tempdir().unwrap();
672        let root = tmp.path();
673        let job_id = "01TESTJOBID0000000000000";
674        let meta = make_meta(job_id, root);
675        let job_dir = JobDir::create(root, job_id, &meta).unwrap();
676        let state = job_dir.init_state(1234, "2024-01-01T00:00:00Z").unwrap();
677
678        // Verify in-memory state.
679        #[cfg(windows)]
680        assert_eq!(
681            state.windows_job_name.as_deref(),
682            Some("AgentExec-01TESTJOBID0000000000000"),
683            "Windows: init_state must set deterministic job name immediately"
684        );
685        #[cfg(not(windows))]
686        assert_eq!(
687            state.windows_job_name, None,
688            "non-Windows: init_state must not set windows_job_name"
689        );
690
691        // Verify persisted state on disk.
692        let persisted = job_dir.read_state().unwrap();
693        #[cfg(windows)]
694        assert_eq!(
695            persisted.windows_job_name.as_deref(),
696            Some("AgentExec-01TESTJOBID0000000000000"),
697            "Windows: persisted state.json must contain windows_job_name"
698        );
699        #[cfg(not(windows))]
700        assert_eq!(
701            persisted.windows_job_name, None,
702            "non-Windows: persisted state.json must not contain windows_job_name"
703        );
704    }
705
706    // ---------- Prefix-based job ID resolution tests ----------
707
708    #[test]
709    fn job_dir_open_exact_match() {
710        let tmp = tempfile::tempdir().unwrap();
711        let root = tmp.path();
712        let job_id = "01JQXK3M8E5PQRSTVWYZ12ABCD";
713        let meta = make_meta(job_id, root);
714        JobDir::create(root, job_id, &meta).unwrap();
715
716        let result = JobDir::open(root, job_id).unwrap();
717        assert_eq!(result.job_id, job_id);
718    }
719
720    #[test]
721    fn job_dir_open_unique_prefix_resolves() {
722        let tmp = tempfile::tempdir().unwrap();
723        let root = tmp.path();
724        let job_id = "01JQXK3M8E5PQRSTVWYZ12ABCD";
725        let meta = make_meta(job_id, root);
726        JobDir::create(root, job_id, &meta).unwrap();
727
728        // Use a unique prefix
729        let result = JobDir::open(root, "01JQXK3M").unwrap();
730        assert_eq!(result.job_id, job_id);
731    }
732
733    #[test]
734    fn job_dir_open_not_found_returns_job_not_found() {
735        let tmp = tempfile::tempdir().unwrap();
736        let root = tmp.path();
737
738        let err = JobDir::open(root, "ZZZZZ").unwrap_err();
739        assert!(
740            err.downcast_ref::<JobNotFound>().is_some(),
741            "expected JobNotFound, got: {err}"
742        );
743    }
744
745    #[test]
746    fn job_dir_open_ambiguous_prefix_returns_ambiguous() {
747        let tmp = tempfile::tempdir().unwrap();
748        let root = tmp.path();
749        let id_a = "01JQXK3M8EAAA00000000000AA";
750        let id_b = "01JQXK3M8EBBB00000000000BB";
751        let meta_a = make_meta(id_a, root);
752        let meta_b = make_meta(id_b, root);
753        JobDir::create(root, id_a, &meta_a).unwrap();
754        JobDir::create(root, id_b, &meta_b).unwrap();
755
756        let err = JobDir::open(root, "01JQXK3M8E").unwrap_err();
757        let ambiguous = err
758            .downcast_ref::<AmbiguousJobId>()
759            .expect("expected AmbiguousJobId");
760        assert_eq!(ambiguous.prefix, "01JQXK3M8E");
761        assert!(ambiguous.candidates.contains(&id_a.to_string()));
762        assert!(ambiguous.candidates.contains(&id_b.to_string()));
763    }
764
765    #[test]
766    fn ambiguous_job_id_display_up_to_5_candidates() {
767        let err = AmbiguousJobId {
768            prefix: "01J".to_string(),
769            candidates: vec![
770                "01JAAA".to_string(),
771                "01JBBB".to_string(),
772                "01JCCC".to_string(),
773            ],
774        };
775        let msg = err.to_string();
776        assert!(msg.contains("01J"), "must include prefix: {msg}");
777        assert!(msg.contains("01JAAA"), "must list candidates: {msg}");
778    }
779
780    #[test]
781    fn ambiguous_job_id_display_truncates_beyond_5() {
782        let candidates: Vec<String> = (1..=8)
783            .map(|i| format!("01JCANDIDATE{i:02}0000000000"))
784            .collect();
785        let err = AmbiguousJobId {
786            prefix: "01J".to_string(),
787            candidates,
788        };
789        let msg = err.to_string();
790        assert!(msg.contains("... and 3 more"), "must truncate: {msg}");
791    }
792}