Skip to main content

leviath_cli/
runstate.rs

1//! On-disk run state for background agent executions.
2//!
3//! Each run lives under `~/.leviath/runs/<run-id>/` with:
4//! - `meta.json`    - run metadata, updated atomically (tmp + rename)
5//! - `output.log`  - append-only combined worker stdout (legacy/fallback)
6//! - `stages.json` - index of per-stage records
7//! - `stages/<idx>/output.log` - readable agent output for that stage
8//! - `stages/<idx>/logs.log`   - operational events + tool activity
9//! - `stages/<idx>/context.json` - context snapshot for that stage
10//!
11//! The dashboard's activity log is persisted separately at:
12//! - `~/.leviath/dashboard.log` - never cleared, appended across sessions
13
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17// The plain run-state data types (RunMeta, RunStatus, the snapshot structs, and
18// the per-stage records) live in `leviath_core::run_meta`. Re-exported here so
19// `crate::runstate::RunMeta` / `runstate::RunMeta` call sites across the cli
20// resolve. All on-disk IO for these types remains in this module.
21pub use leviath_core::run_meta::{
22    ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus, StageRecord,
23    StageRunStatus,
24};
25
26/// Atomically write a context snapshot for the run.
27pub fn write_context_snapshot(run_id: &str, snap: &ContextSnapshot) -> anyhow::Result<()> {
28    write_context_snapshot_to(&run_dir(run_id), snap)
29}
30
31/// Atomically write pre-serialized `json` to `path` (via a `.json.tmp`
32/// sibling + rename).
33///
34/// Non-generic (takes an already-serialized string) so it has a single
35/// monomorphization and every region - including the `std::fs` error `?`
36/// arms - is exercised by real tests. Serialization is performed by the
37/// callers, whose concrete production types
38/// (`ContextSnapshot`/`RunMeta`/`&[StageRecord]`) are provably infallible to
39/// serialize (see the `.expect` sites).
40fn write_json_atomic(path: &std::path::Path, json: &str) -> anyhow::Result<()> {
41    let tmp = path.with_extension("json.tmp");
42    // `write_private`: these files carry the run's full task prompt,
43    // conversation and tool output - and `meta.json` carries the webhook
44    // signing secret. They were written with a plain `fs::write` at the umask
45    // default (typically 0644), protected only by the 0700 on the enclosing run
46    // directory. That is one `chmod` away from being readable, and defence in
47    // depth is the whole point of a mode on the file itself.
48    leviath_sys::write_private(&tmp, json.as_bytes())?;
49    std::fs::rename(&tmp, path)?;
50    Ok(())
51}
52
53fn write_context_snapshot_to(dir: &std::path::Path, snap: &ContextSnapshot) -> anyhow::Result<()> {
54    let json = serde_json::to_string_pretty(snap)
55        .expect("infallible: ContextSnapshot always serializes to JSON");
56    write_json_atomic(&dir.join("context.json"), &json)
57}
58
59/// Read the context snapshot for a run, if present.
60pub fn read_context_snapshot(run_id: &str) -> Option<ContextSnapshot> {
61    let path = run_dir(run_id).join("context.json");
62    let json = std::fs::read_to_string(&path).ok()?;
63    serde_json::from_str(&json).ok()
64}
65
66/// Read + parse a run's portable archive (`<run_dir>/run.lvr`), returning its
67/// records, or `None` if the archive is missing or unreadable.
68pub fn read_run_archive(run_id: &str) -> Option<Vec<leviath_core::run_archive::RunRecord>> {
69    let path = run_dir(run_id).join("run.lvr");
70    let bytes = std::fs::read(&path).ok()?;
71    leviath_core::run_archive::read_archive(&mut bytes.as_slice())
72        .ok()
73        .map(|(_version, records)| records)
74}
75
76/// A run's context-window history: the full window (+ metadata) at each recorded
77/// point over time, oldest first. Empty when there's no readable archive.
78pub fn context_history(run_id: &str) -> Vec<leviath_core::run_archive::RunPoint> {
79    read_run_archive(run_id)
80        .map(|records| leviath_core::run_archive::replay_points(&records))
81        .unwrap_or_default()
82}
83
84fn now_secs() -> i64 {
85    SystemTime::now()
86        .duration_since(UNIX_EPOCH)
87        .map(|d| d.as_secs() as i64)
88        .unwrap_or(0)
89}
90
91/// Inner implementation of `runs_dir`, parameterised so it can be tested
92/// without touching the process-global env. All callers go through `runs_dir`.
93///
94/// The fallback resolves through [`crate::config::leviath_home_dir`], not
95/// `dirs::home_dir` directly, so `LEVIATH_HOME` redirects the runs dir like it
96/// redirects the config, the control socket and the agents dir. With the raw
97/// OS home instead, a test that sets `LEVIATH_HOME` would be isolated
98/// everywhere *except* here and still write runs into the developer's real
99/// `~/.leviath/runs`. `LEVIATH_RUNS_DIR` wins over both.
100fn runs_dir_from(env_override: Option<&str>) -> PathBuf {
101    if let Some(dir) = env_override {
102        return PathBuf::from(dir);
103    }
104    leviath_core::paths::data_dir()
105        .unwrap_or_default()
106        .join("runs")
107}
108
109/// Directory where all run state is stored.
110pub fn runs_dir() -> PathBuf {
111    runs_dir_from(std::env::var("LEVIATH_RUNS_DIR").ok().as_deref())
112}
113
114/// Directory for a specific run.
115///
116/// A `run_id` that is not a single safe path component resolves to
117/// `<runs_dir>/<invalid>`, a name that cannot exist - so a caller that passes an
118/// attacker-supplied id gets a miss rather than a traversal. `run_id` reaches
119/// this from URL segments on `GET /api/agents/{id}/logs` and friends, where
120/// `Path::join` would otherwise happily accept `../../` or an absolute path.
121///
122/// Returning a definitely-missing path rather than an `Option` keeps every
123/// caller's "no such run" branch as the single failure path, instead of adding a
124/// second one that all of them would have to handle identically.
125pub fn run_dir(run_id: &str) -> PathBuf {
126    if !leviath_core::is_safe_path_component(run_id) {
127        tracing::warn!(run_id = %run_id, "rejected an unsafe run id");
128        return runs_dir().join("<invalid>");
129    }
130    runs_dir().join(run_id)
131}
132
133/// Inner implementation of `dashboard_log_path`, parameterised so it can be
134/// tested without touching the process-global env. All callers go through
135/// `dashboard_log_path`.
136fn dashboard_log_path_from(env_override: Option<&str>) -> PathBuf {
137    if let Some(path) = env_override {
138        return PathBuf::from(path);
139    }
140    leviath_core::paths::data_dir()
141        .unwrap_or_default()
142        .join("dashboard.log")
143}
144
145/// Path to the persistent dashboard activity log (~/.leviath/dashboard.log).
146///
147/// Honours the `LEVIATH_DASHBOARD_LOG_PATH` override when set (tests use it via
148/// `isolate_runs_dir_for_test`); otherwise resolves the real home-relative
149/// path. This function only *computes* a `PathBuf` - it never writes - so both
150/// arms are safe to exercise directly in tests. The write side
151/// ([`append_dashboard_log`] and `Dashboard::add_log`) is what must stay off
152/// the user's real log in tests: `append_dashboard_log`'s own tests set the
153/// override, and `Dashboard` carries an injected log path (a temp dir under
154/// `make_test_dashboard`) so no dashboard-input test ever appends to the real
155/// `~/.leviath/dashboard.log`.
156pub fn dashboard_log_path() -> PathBuf {
157    match std::env::var("LEVIATH_DASHBOARD_LOG_PATH") {
158        Ok(path) => dashboard_log_path_from(Some(&path)),
159        Err(_) => dashboard_log_path_from(None),
160    }
161}
162
163/// Append a timestamped line to the persistent dashboard activity log at the
164/// default [`dashboard_log_path`]. Silently ignores I/O errors - best-effort.
165pub fn append_dashboard_log(msg: &str) {
166    append_dashboard_log_to(&dashboard_log_path(), msg);
167}
168
169/// Append a timestamped line to the dashboard activity log at an explicit
170/// `path`. Silently ignores I/O errors - the dashboard log is best-effort.
171///
172/// The path is a parameter so `Dashboard` can inject a test-isolated log
173/// location, guaranteeing no dashboard-input test appends to the user's real
174/// `~/.leviath/dashboard.log` (see [`dashboard_log_path`]).
175pub fn append_dashboard_log_to(path: &Path, msg: &str) {
176    append_dashboard_log_capped(path, msg, DASHBOARD_LOG_MAX_BYTES);
177}
178
179/// The dashboard log is capped at this size; once the live file reaches it, the
180/// file is rolled (see [`roll_log_if_over_cap`]) so it can't grow without bound
181/// across a long-lived daemon's lifetime.
182const DASHBOARD_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
183
184/// Append with an explicit cap (the public entry points use
185/// [`DASHBOARD_LOG_MAX_BYTES`]; tests pass a small cap to exercise rolling).
186fn append_dashboard_log_capped(path: &Path, msg: &str, max_bytes: u64) {
187    use std::io::Write;
188    // Ensure the parent directory exists (first-run case).
189    if let Some(parent) = path.parent() {
190        let _ = std::fs::create_dir_all(parent);
191    }
192    roll_log_if_over_cap(path, max_bytes);
193    if let Ok(mut file) = std::fs::OpenOptions::new()
194        .create(true)
195        .append(true)
196        .open(path)
197    {
198        let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
199        let _ = writeln!(file, "{} {}", timestamp, msg);
200    }
201}
202
203/// The path the rolled (previous-generation) log is moved to: `<name>.1`.
204fn rolled_log_path(path: &Path) -> PathBuf {
205    let mut name = path.as_os_str().to_owned();
206    name.push(".1");
207    PathBuf::from(name)
208}
209
210/// Roll the live log to `<name>.1` once it reaches `max_bytes`, replacing any
211/// existing rolled file, so the live file restarts empty and at most one
212/// previous generation is retained (bounded ~2×cap on disk). Best-effort - a
213/// failed rename just leaves the log to keep growing rather than erroring.
214fn roll_log_if_over_cap(path: &Path, max_bytes: u64) {
215    let over = std::fs::metadata(path)
216        .map(|m| m.len() >= max_bytes)
217        .unwrap_or(false);
218    if over {
219        let _ = std::fs::rename(path, rolled_log_path(path));
220    }
221}
222
223/// How many random bits go in a run ID's suffix, rendered as 12 hex digits.
224/// Collisions only matter within one wall-clock second for one agent name, so 48
225/// bits is many orders of magnitude more than needed while staying short enough
226/// to read in `lev ps` and the dashboard.
227const RUN_ID_ENTROPY_BITS: u32 = 48;
228
229/// Generate a unique run ID: `<agent_name>-<timestamp>-<random>`.
230///
231/// The suffix is **random**, not derived. A derived suffix like
232/// `(now ^ (now >> 16) ^ counter)` over a process-local counter defends a
233/// `lev run --count N` batch inside one process but degenerates to a pure
234/// function of the current second across separate processes: three concurrent
235/// `lev run` invocations all mint `fetcher-1785127214-8b48` and silently share
236/// one run directory. Nothing downstream detects that - `create_dir_all` is a
237/// no-op on an existing directory and the persistence worker then
238/// last-writer-wins over `meta.json` / `context.json` / `run.lvr`, interleaving
239/// two runs' state irrecoverably.
240///
241/// The `<name>-<secs>-<hex>` shape is preserved: the timestamp keeps IDs sorting
242/// and reading chronologically, and the dashboard's short-ID display
243/// (`split('-').next_back()`) still lands on the unique component.
244pub fn new_run_id(agent_name: &str) -> String {
245    use rand::RngExt as _;
246    let entropy: u64 = rand::rng().random::<u64>() >> (u64::BITS - RUN_ID_ENTROPY_BITS);
247    let safe_name = agent_name.replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
248    format!("{}-{}-{:012x}", safe_name, now_secs(), entropy)
249}
250
251/// Create the run directory and write initial metadata.
252pub fn create_run(meta: &RunMeta) -> anyhow::Result<()> {
253    create_run_in(&run_dir(&meta.run_id), meta)
254}
255
256/// Create an explicit run directory and write initial metadata into it.
257///
258/// Callers that already know the directory should prefer this over
259/// [`create_run`], which resolves it from the home directory - the daemon's
260/// spawner stakes out the run dir under its own configured `runs_dir`.
261pub(crate) fn create_run_in(dir: &std::path::Path, meta: &RunMeta) -> anyhow::Result<()> {
262    std::fs::create_dir_all(dir)?;
263
264    // Restrict the run directory to owner-only (no-op on non-Unix).
265    let _ = leviath_sys::secure_dir_perms(dir);
266
267    write_meta_to(dir, meta)
268}
269
270/// Atomically write run metadata (write to tmp, then rename).
271pub fn write_meta(meta: &RunMeta) -> anyhow::Result<()> {
272    write_meta_to(&run_dir(&meta.run_id), meta)
273}
274
275/// Atomically write `meta.json` into an explicit run directory.
276///
277/// Callers that already know the directory should prefer this over
278/// [`write_meta`], which resolves it from the home directory - the daemon's
279/// recovery pass works from its configured `runs_dir` instead.
280pub(crate) fn write_meta_to(dir: &std::path::Path, meta: &RunMeta) -> anyhow::Result<()> {
281    let json =
282        serde_json::to_string_pretty(meta).expect("infallible: RunMeta always serializes to JSON");
283    write_json_atomic(&dir.join("meta.json"), &json)
284}
285
286/// Read run metadata for a given run ID.
287pub fn read_meta(run_id: &str) -> anyhow::Result<RunMeta> {
288    read_meta_from(&run_dir(run_id))
289}
290
291/// Whether an on-disk run status means the run has finished and should be left
292/// alone. `Starting`/`Running`/`WaitingInput` are all "still going" as far as
293/// anything reading the runs dir is concerned.
294pub fn is_terminal_status(status: &RunStatus) -> bool {
295    matches!(
296        status,
297        RunStatus::Complete
298            | RunStatus::CompleteInteractive
299            | RunStatus::Error
300            | RunStatus::Cancelled
301    )
302}
303
304/// The outcome of forcing a run to a terminal state on disk.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ForceCancelOutcome {
307    /// The run was live on disk and is now recorded `Cancelled`.
308    Cancelled,
309    /// The run was already finished; nothing was written.
310    AlreadyTerminal,
311    /// No run directory with that id exists.
312    NoSuchRun,
313    /// The directory exists but its metadata could not be rewritten.
314    WriteFailed,
315}
316
317impl ForceCancelOutcome {
318    /// Whether the id named a run at all - i.e. whether the cancel had a target,
319    /// regardless of whether it needed to write anything.
320    pub fn found_run(&self) -> bool {
321        !matches!(self, Self::NoSuchRun)
322    }
323}
324
325/// Force a run's on-disk metadata to `Cancelled`, in the runs dir resolved from
326/// the environment. See [`force_cancel_in`].
327pub fn force_cancel(run_id: &str) -> ForceCancelOutcome {
328    force_cancel_in(&run_dir(run_id), now_secs())
329}
330
331/// Force the run in `run_dir` to `Cancelled`, stamping `updated_at` with `now`.
332///
333/// This is the floor under every kill path: it needs nothing but the filesystem,
334/// so it works for a run the daemon can't rebuild (blueprint deleted, metadata
335/// corrupt, died mid-spawn) and for a run whose daemon is gone entirely. Both
336/// the daemon's force-terminator seam and `lev cancel --force` route here so
337/// there is one definition of "terminated on disk".
338///
339/// A directory whose `meta.json` is missing or unparseable still gets a minimal
340/// `Cancelled` record written: such a run is otherwise skipped by `list_runs`,
341/// which makes it invisible *and* permanent.
342pub fn force_cancel_in(run_dir: &Path, now: i64) -> ForceCancelOutcome {
343    if !run_dir.is_dir() {
344        return ForceCancelOutcome::NoSuchRun;
345    }
346    let run_id = run_dir
347        .file_name()
348        .map(|n| n.to_string_lossy().into_owned())
349        .unwrap_or_default();
350    let cancelled = match read_meta_from(run_dir) {
351        Ok(meta) if is_terminal_status(&meta.status) => return ForceCancelOutcome::AlreadyTerminal,
352        Ok(meta) => RunMeta {
353            status: RunStatus::Cancelled,
354            updated_at: now,
355            ..meta
356        },
357        // Unreadable metadata: synthesize just enough to record the outcome. The
358        // run id is the directory name, which is the one field always recoverable.
359        Err(_) => RunMeta {
360            status: RunStatus::Cancelled,
361            updated_at: now,
362            error: Some("run metadata was unreadable; cancelled".to_string()),
363            ..RunMeta::new(
364                run_id.clone(),
365                run_id,
366                String::new(),
367                String::new(),
368                None,
369                String::new(),
370                0,
371            )
372        },
373    };
374    match write_meta_to(run_dir, &cancelled) {
375        Ok(()) => ForceCancelOutcome::Cancelled,
376        Err(e) => {
377            // Formatted outside the macro: a method call inside a `%field` is
378            // only evaluated when a subscriber visits the value, so it would go
379            // unexercised under the tests' no-op subscriber.
380            let path = run_dir.display().to_string();
381            tracing::warn!(
382                run_dir = %path,
383                error = %e,
384                "could not force a run to cancelled on disk"
385            );
386            ForceCancelOutcome::WriteFailed
387        }
388    }
389}
390
391/// Read run metadata out of an explicit run directory (the daemon works from its
392/// own configured `runs_dir` rather than the home-resolved one).
393pub(crate) fn read_meta_from(dir: &std::path::Path) -> anyhow::Result<RunMeta> {
394    let path = dir.join("meta.json");
395    let json = std::fs::read_to_string(&path)?;
396    Ok(serde_json::from_str(&json)?)
397}
398
399/// Inner implementation of `list_runs`, parameterised so the early-return
400/// branch can be exercised in tests without deleting real on-disk state.
401fn list_runs_in_dir(dir: PathBuf) -> Vec<RunMeta> {
402    if !dir.exists() {
403        return Vec::new();
404    }
405
406    let mut runs = Vec::new();
407
408    if let Ok(entries) = std::fs::read_dir(&dir) {
409        for entry in entries.filter_map(|e| e.ok()) {
410            let meta_path = entry.path().join("meta.json");
411            if let Ok(json) = std::fs::read_to_string(&meta_path)
412                && let Ok(meta) = serde_json::from_str::<RunMeta>(&json)
413            {
414                runs.push(meta);
415            }
416        }
417    }
418
419    runs.sort_by_key(|r| std::cmp::Reverse(r.started_at));
420    runs
421}
422
423/// List all runs, sorted by started_at descending (most recent first).
424/// Silently skips any runs whose metadata cannot be read.
425pub fn list_runs() -> Vec<RunMeta> {
426    list_runs_in_dir(runs_dir())
427}
428
429/// Read the last `max_bytes` of any file on disk, returning UTF-8 text.
430/// If the file is smaller than `max_bytes` the whole file is returned.
431/// Partial UTF-8 at the truncation boundary is handled by skipping to the
432/// first newline.  Returns an empty string on any I/O error.
433pub fn tail_file(path: &std::path::Path, max_bytes: u64) -> String {
434    use std::io::{Read, Seek, SeekFrom};
435
436    let mut file = match std::fs::File::open(path) {
437        Ok(f) => f,
438        Err(_) => return String::new(),
439    };
440
441    // Use fstat on the open fd rather than a separate stat() call - avoids the
442    // TOCTOU window between existence check and metadata read. Falls back to 0
443    // (read everything) if fstat somehow fails on an already-open fd.
444    let file_size = file.metadata().map(|m| m.len()).unwrap_or(0);
445
446    if file_size <= max_bytes {
447        let mut buf = Vec::new();
448        let _ = file.read_to_end(&mut buf);
449        return String::from_utf8_lossy(&buf).to_string();
450    }
451
452    let offset = file_size - max_bytes;
453    let _ = file.seek(SeekFrom::Start(offset));
454
455    let mut buf = Vec::new();
456    let _ = file.read_to_end(&mut buf);
457
458    // Skip to the first newline so we don't emit a partial line at the start.
459    if let Some(nl) = buf.iter().position(|&b| b == b'\n') {
460        String::from_utf8_lossy(&buf[nl + 1..]).to_string()
461    } else {
462        String::from_utf8_lossy(&buf).to_string()
463    }
464}
465
466// ─── Per-stage persistence ────────────────────────────────────────────────────
467
468/// Directory for per-stage files within a run.
469pub fn stage_dir(run_id: &str, stage_idx: usize) -> PathBuf {
470    run_dir(run_id).join("stages").join(stage_idx.to_string())
471}
472
473/// Atomically write the stages index for a run.
474pub fn write_stages_index(run_id: &str, stages: &[StageRecord]) -> anyhow::Result<()> {
475    write_stages_index_to(&run_dir(run_id), stages)
476}
477
478fn write_stages_index_to(dir: &std::path::Path, stages: &[StageRecord]) -> anyhow::Result<()> {
479    let json = serde_json::to_string_pretty(&stages)
480        .expect("infallible: StageRecord slice always serializes to JSON");
481    write_json_atomic(&dir.join("stages.json"), &json)
482}
483
484/// Read the stages index for a run, or return an empty vec on any error.
485pub fn read_stages_index(run_id: &str) -> Vec<StageRecord> {
486    let path = run_dir(run_id).join("stages.json");
487    let json = match std::fs::read_to_string(&path) {
488        Ok(j) => j,
489        Err(_) => return Vec::new(),
490    };
491    serde_json::from_str(&json).unwrap_or_default()
492}
493
494/// Ensure the per-stage directory exists (called before first write).
495fn ensure_stage_dir(run_id: &str, stage_idx: usize) {
496    let dir = stage_dir(run_id, stage_idx);
497    let _ = std::fs::create_dir_all(&dir);
498}
499
500/// Append a line of readable agent output to the per-stage output log.
501pub fn append_stage_output(run_id: &str, stage_idx: usize, text: &str) {
502    use std::io::Write;
503    ensure_stage_dir(run_id, stage_idx);
504    let path = stage_dir(run_id, stage_idx).join("output.log");
505    if let Ok(mut file) = std::fs::OpenOptions::new()
506        .create(true)
507        .append(true)
508        .open(&path)
509    {
510        let _ = writeln!(file, "{}", text);
511    }
512}
513
514/// Append a line of operational/tool-activity log to the per-stage logs file.
515pub fn append_stage_log(run_id: &str, stage_idx: usize, text: &str) {
516    use std::io::Write;
517    ensure_stage_dir(run_id, stage_idx);
518    let path = stage_dir(run_id, stage_idx).join("logs.log");
519    if let Ok(mut file) = std::fs::OpenOptions::new()
520        .create(true)
521        .append(true)
522        .open(&path)
523    {
524        let _ = writeln!(file, "{}", text);
525    }
526}
527
528/// Atomically write a context snapshot for a specific stage.
529pub fn write_stage_context(
530    run_id: &str,
531    stage_idx: usize,
532    snap: &ContextSnapshot,
533) -> anyhow::Result<()> {
534    ensure_stage_dir(run_id, stage_idx);
535    write_context_snapshot_to(&stage_dir(run_id, stage_idx), snap)
536}
537
538/// Read the context snapshot for a specific stage, if present.
539pub fn read_stage_context(run_id: &str, stage_idx: usize) -> Option<ContextSnapshot> {
540    let path = stage_dir(run_id, stage_idx).join("context.json");
541    let json = std::fs::read_to_string(&path).ok()?;
542    serde_json::from_str(&json).ok()
543}
544
545/// Read the last `max_bytes` of the readable output log for a specific stage.
546pub fn tail_stage_output(run_id: &str, stage_idx: usize, max_bytes: u64) -> String {
547    tail_file(&stage_dir(run_id, stage_idx).join("output.log"), max_bytes)
548}
549
550/// Read the last `max_bytes` of the operational log for a specific stage.
551pub fn tail_stage_log(run_id: &str, stage_idx: usize, max_bytes: u64) -> String {
552    tail_file(&stage_dir(run_id, stage_idx).join("logs.log"), max_bytes)
553}
554
555/// Build the isolated base directory for a run-state test and create its
556/// `runs/` subdir. Returned so the caller's closure can plant fixtures under it.
557///
558/// Rooted under `~/.leviath-test/rs-<hash>` rather than `std::env::temp_dir()`:
559/// some dashboard render tests display a real on-disk path inside a fixed-width
560/// terminal area and assert on a substring near its *end*, and macOS's real
561/// temp dir (`/var/folders/xy/.../T/`) is long enough to push realistic paths
562/// past the render width and truncate the asserted suffix. `unique` is hashed
563/// short for the same reason (test names run 60+ chars). `.leviath-test` is a
564/// sibling of `.leviath`, never read by `lev dash`/`lev serve`, so even if a
565/// killed test process skips cleanup it can't leak into the real dashboard.
566#[cfg(test)]
567fn make_runs_base_dir(unique: &str) -> std::path::PathBuf {
568    use std::hash::{Hash, Hasher};
569    let mut hasher = std::collections::hash_map::DefaultHasher::new();
570    unique.hash(&mut hasher);
571    let short = format!("{:x}", hasher.finish() & 0xffff_ffff);
572    let base_dir = dirs::home_dir()
573        .unwrap_or_default()
574        .join(".leviath-test")
575        .join(format!("rs-{short}"));
576    let _ = std::fs::create_dir_all(base_dir.join("runs"));
577    base_dir
578}
579
580/// The env overrides that point run-state I/O at `base_dir` instead of the
581/// real `~/.leviath/`. Handed to `temp_env` for scoped set-and-restore.
582#[cfg(test)]
583fn runs_dir_isolation_vars(
584    base_dir: &std::path::Path,
585) -> [(&'static str, Option<std::ffi::OsString>); 2] {
586    [
587        (
588            "LEVIATH_RUNS_DIR",
589            Some(base_dir.join("runs").into_os_string()),
590        ),
591        (
592            "LEVIATH_DASHBOARD_LOG_PATH",
593            Some(base_dir.join("dashboard.log").into_os_string()),
594        ),
595    ]
596}
597
598/// Runs `f` with `LEVIATH_RUNS_DIR`/`LEVIATH_DASHBOARD_LOG_PATH` pointed at a
599/// fresh isolated temp directory (passed to `f`), restoring them afterwards.
600/// Closure-scoped (not an RAII guard) because edition 2024 makes `set_var`
601/// `unsafe`, which the crate forbids; `temp_env` serializes it process-wide.
602#[cfg(test)]
603pub(crate) fn with_isolated_runs_dir<R>(unique: &str, f: impl FnOnce(&std::path::Path) -> R) -> R {
604    let base_dir = make_runs_base_dir(unique);
605    let result = temp_env::with_vars(runs_dir_isolation_vars(&base_dir), || f(&base_dir));
606    let _ = std::fs::remove_dir_all(&base_dir);
607    result
608}
609
610/// Async counterpart of [`with_isolated_runs_dir`] for `#[tokio::test]`s.
611#[cfg(test)]
612pub(crate) async fn with_isolated_runs_dir_async<R, Fut>(
613    unique: &str,
614    f: impl FnOnce(std::path::PathBuf) -> Fut,
615) -> R
616where
617    Fut: std::future::Future<Output = R>,
618{
619    let base_dir = make_runs_base_dir(unique);
620    let result =
621        temp_env::async_with_vars(runs_dir_isolation_vars(&base_dir), f(base_dir.clone())).await;
622    let _ = std::fs::remove_dir_all(&base_dir);
623    result
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    /// `run_id` arrives from URL segments on `GET /api/agents/{id}/logs` and
631    /// friends. `Path::join` neither normalizes `..` nor resists an absolute
632    /// path, so an unvalidated id read files anywhere. An unsafe one resolves to
633    /// a name that cannot exist, giving the caller a plain miss.
634    #[test]
635    fn run_dir_refuses_an_unsafe_run_id() {
636        crate::test_support::with_tracing(|| {
637            for bad in ["../../etc", "/etc/passwd", "..", "a/b"] {
638                let dir = run_dir(bad);
639                let shown = dir.display().to_string();
640                assert!(dir.ends_with("<invalid>"), "{bad} resolved to {shown}");
641                assert!(!dir.exists(), "{bad} must not resolve to a real path");
642            }
643            // An ordinary id is untouched.
644            assert!(run_dir("run-abc123").ends_with("run-abc123"));
645        });
646    }
647
648    #[test]
649    fn write_json_atomic_fs_write_failure() {
650        // Drive the `std::fs::write(&tmp, json)?` error arm: writing the
651        // `.json.tmp` sibling into a directory that does not exist fails.
652        let path = std::path::Path::new("/nonexistent/leviath/runstate-cov/out.json");
653        let result = write_json_atomic(path, "{}");
654        assert!(result.is_err());
655        assert!(!path.exists());
656    }
657
658    // ─── RunStatus ──────────────────────────────────────────────────────────
659
660    #[test]
661    fn run_status_serde_roundtrip() {
662        for status in [
663            RunStatus::Starting,
664            RunStatus::Running,
665            RunStatus::WaitingInput,
666            RunStatus::Complete,
667            RunStatus::CompleteInteractive,
668            RunStatus::Error,
669            RunStatus::Cancelled,
670        ] {
671            let json = serde_json::to_string(&status).unwrap();
672            let back: RunStatus = serde_json::from_str(&json).unwrap();
673            assert_eq!(status, back);
674        }
675    }
676
677    #[test]
678    fn run_status_display() {
679        assert_eq!(RunStatus::Starting.to_string(), "Starting");
680        assert_eq!(RunStatus::Running.to_string(), "Running");
681        assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
682        assert_eq!(RunStatus::Complete.to_string(), "Complete");
683        assert_eq!(
684            RunStatus::CompleteInteractive.to_string(),
685            "CompleteInteractive"
686        );
687        assert_eq!(RunStatus::Error.to_string(), "Error");
688        assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
689    }
690
691    #[test]
692    fn run_status_snake_case_serialization() {
693        let json = serde_json::to_string(&RunStatus::WaitingInput).unwrap();
694        assert_eq!(json, "\"waiting_input\"");
695        let json = serde_json::to_string(&RunStatus::CompleteInteractive).unwrap();
696        assert_eq!(json, "\"complete_interactive\"");
697    }
698
699    // ─── StageRunStatus ─────────────────────────────────────────────────────
700
701    #[test]
702    fn stage_run_status_serde_roundtrip() {
703        for status in [
704            StageRunStatus::Pending,
705            StageRunStatus::Active,
706            StageRunStatus::WaitingInput,
707            StageRunStatus::Complete,
708            StageRunStatus::Error,
709        ] {
710            let json = serde_json::to_string(&status).unwrap();
711            let back: StageRunStatus = serde_json::from_str(&json).unwrap();
712            assert_eq!(status, back);
713        }
714    }
715
716    #[test]
717    fn stage_run_status_display() {
718        assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
719        assert_eq!(StageRunStatus::Active.to_string(), "Active");
720        assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
721        assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
722        assert_eq!(StageRunStatus::Error.to_string(), "Error");
723    }
724
725    // ─── RunMeta ────────────────────────────────────────────────────────────
726
727    #[test]
728    fn run_meta_new_defaults() {
729        let meta = RunMeta::new(
730            "run-1".into(),
731            "agent".into(),
732            "/path".into(),
733            "do stuff".into(),
734            Some("gpt-4".into()),
735            "/work".into(),
736            3,
737        );
738        assert_eq!(meta.run_id, "run-1");
739        assert_eq!(meta.agent_name, "agent");
740        assert_eq!(meta.task, "do stuff");
741        assert_eq!(meta.model.as_deref(), Some("gpt-4"));
742        assert_eq!(meta.num_stages, 3);
743        assert_eq!(meta.status, RunStatus::Starting);
744        assert_eq!(meta.pid, 0);
745        assert_eq!(meta.stage_index, 0);
746        assert!(meta.error.is_none());
747        assert!(meta.title.is_none());
748        assert!(meta.metadata.is_empty());
749        assert!(meta.callback_url.is_none());
750        assert!(meta.parent_run_id.is_none());
751    }
752
753    #[test]
754    fn run_meta_serde_roundtrip() {
755        let meta = RunMeta::new(
756            "test-run".into(),
757            "test-agent".into(),
758            "/agents/test".into(),
759            "run tests".into(),
760            None,
761            "/tmp".into(),
762            2,
763        );
764        let json = serde_json::to_string_pretty(&meta).unwrap();
765        let back: RunMeta = serde_json::from_str(&json).unwrap();
766        assert_eq!(back.run_id, "test-run");
767        assert_eq!(back.agent_name, "test-agent");
768        assert_eq!(back.num_stages, 2);
769        assert!(back.model.is_none());
770    }
771
772    #[test]
773    fn run_meta_touch_updates_timestamp() {
774        let mut meta = RunMeta::new(
775            "r".into(),
776            "a".into(),
777            "/p".into(),
778            "t".into(),
779            None,
780            "/w".into(),
781            1,
782        );
783        let before = meta.updated_at;
784        // Touch should update (or at least not decrease) updated_at
785        meta.touch();
786        assert!(meta.updated_at >= before);
787    }
788
789    #[test]
790    fn run_meta_optional_fields_deserialize() {
791        // Simulate a meta.json without optional fields (e.g., from older version)
792        let json = serde_json::json!({
793            "run_id": "r1",
794            "agent_name": "a",
795            "agent_path": "/p",
796            "task": "t",
797            "model": null,
798            "pid": 123,
799            "status": "running",
800            "current_stage": "init",
801            "stage_index": 0,
802            "num_stages": 1,
803            "iteration": 0,
804            "prompt_tokens": 0,
805            "completion_tokens": 0,
806            "workdir": "/w",
807            "started_at": 1000,
808            "updated_at": 1000,
809            "error": null
810        });
811        let meta: RunMeta = serde_json::from_value(json).unwrap();
812        assert_eq!(meta.cached_tokens, 0);
813        assert!(meta.title.is_none());
814        assert!(meta.metadata.is_empty());
815        assert!(meta.callback_url.is_none());
816        assert!(meta.parent_run_id.is_none());
817    }
818
819    // ─── StageRecord ────────────────────────────────────────────────────────
820
821    #[test]
822    fn stage_record_new_defaults() {
823        let rec = StageRecord::new("analyze".into(), 2);
824        assert_eq!(rec.name, "analyze");
825        assert_eq!(rec.index, 2);
826        assert_eq!(rec.status, StageRunStatus::Pending);
827        assert_eq!(rec.prompt_tokens, 0);
828        assert_eq!(rec.completion_tokens, 0);
829        assert_eq!(rec.cached_tokens, 0);
830        assert!(rec.started_at.is_none());
831        assert!(rec.ended_at.is_none());
832    }
833
834    #[test]
835    fn stage_record_serde_roundtrip() {
836        let mut rec = StageRecord::new("build".into(), 0);
837        rec.status = StageRunStatus::Complete;
838        rec.prompt_tokens = 100;
839        rec.started_at = Some(1000);
840        rec.ended_at = Some(2000);
841
842        let json = serde_json::to_string(&rec).unwrap();
843        let back: StageRecord = serde_json::from_str(&json).unwrap();
844        assert_eq!(back.name, "build");
845        assert_eq!(back.status, StageRunStatus::Complete);
846        assert_eq!(back.prompt_tokens, 100);
847        assert_eq!(back.started_at, Some(1000));
848    }
849
850    // ─── RegionSnapshot / ContextSnapshot ───────────────────────────────────
851
852    #[test]
853    fn region_snapshot_serde_roundtrip() {
854        let snap = RegionSnapshot {
855            name: "system".into(),
856            kind: "pinned".into(),
857            current_tokens: 100,
858            max_tokens: 500,
859            entries: vec![RegionEntrySnapshot {
860                content: "You are helpful".into(),
861                tokens: 3,
862                kind: Default::default(),
863                metadata: None,
864                key: None,
865                taint: Default::default(),
866            }],
867        };
868        let json = serde_json::to_string(&snap).unwrap();
869        let back: RegionSnapshot = serde_json::from_str(&json).unwrap();
870        assert_eq!(back.name, "system");
871        assert_eq!(back.entries.len(), 1);
872        assert_eq!(back.entries[0].content, "You are helpful");
873    }
874
875    #[test]
876    fn region_snapshot_empty_entries_omitted() {
877        let snap = RegionSnapshot {
878            name: "empty".into(),
879            kind: "temporary".into(),
880            current_tokens: 0,
881            max_tokens: 100,
882            entries: vec![],
883        };
884        let json = serde_json::to_value(&snap).unwrap();
885        assert!(json.get("entries").is_none());
886    }
887
888    #[test]
889    fn context_snapshot_serde_roundtrip() {
890        let snap = ContextSnapshot {
891            stage_name: "analyze".into(),
892            total_tokens: 500,
893            max_tokens: 8192,
894            regions: vec![RegionSnapshot {
895                name: "history".into(),
896                kind: "sliding".into(),
897                current_tokens: 300,
898                max_tokens: 2000,
899                entries: vec![],
900            }],
901        };
902        let json = serde_json::to_string(&snap).unwrap();
903        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
904        assert_eq!(back.stage_name, "analyze");
905        assert_eq!(back.total_tokens, 500);
906        assert_eq!(back.regions.len(), 1);
907    }
908
909    // ─── tail_file ──────────────────────────────────────────────────────────
910
911    #[test]
912    fn tail_file_nonexistent_returns_empty() {
913        let path = std::path::Path::new("/tmp/nonexistent-leviath-test-file.txt");
914        assert_eq!(tail_file(path, 1024), "");
915    }
916
917    #[test]
918    fn tail_file_small_file_returns_all() {
919        let dir = tempfile::tempdir().unwrap();
920        let path = dir.path().join("small.txt");
921        std::fs::write(&path, "line1\nline2\nline3\n").unwrap();
922        let result = tail_file(&path, 1024);
923        assert_eq!(result, "line1\nline2\nline3\n");
924    }
925
926    #[test]
927    fn tail_file_large_file_returns_tail() {
928        let dir = tempfile::tempdir().unwrap();
929        let path = dir.path().join("large.txt");
930        let content = "abcdefghij\n".repeat(100); // 1100 bytes
931        std::fs::write(&path, &content).unwrap();
932        let result = tail_file(&path, 50);
933        // Should be less than 50 bytes, starting from a line boundary
934        assert!(result.len() <= 50);
935        assert!(result.ends_with('\n'));
936    }
937
938    // ─── new_run_id ─────────────────────────────────────────────────────────
939
940    #[test]
941    fn new_run_id_contains_agent_name() {
942        let id = new_run_id("my-agent");
943        assert!(id.starts_with("my-agent-"));
944    }
945
946    #[test]
947    fn new_run_id_sanitizes_special_chars() {
948        let id = new_run_id("agent with spaces!");
949        assert!(!id.contains(' '));
950        assert!(!id.contains('!'));
951    }
952
953    #[test]
954    fn new_run_id_is_unique_across_rapid_calls_in_same_second() {
955        // `--count N` calls `new_run_id` N times in a tight loop, all within the
956        // same wall-clock second.
957        let ids: std::collections::HashSet<String> =
958            (0..100).map(|_| new_run_id("same-agent")).collect();
959        assert_eq!(ids.len(), 100);
960    }
961
962    /// Split `<name>-<secs>-<hex>` from the right - the agent name itself may
963    /// contain dashes.
964    fn split_run_id(id: &str) -> (&str, &str) {
965        let mut parts = id.rsplitn(3, '-');
966        let suffix = parts.next().expect("run id has a suffix");
967        let secs = parts.next().expect("run id has a timestamp");
968        (secs, suffix)
969    }
970
971    #[test]
972    fn new_run_id_suffix_is_random_not_derived_from_the_clock() {
973        // The collision this guards against is *across processes*: a suffix
974        // derived as `(now ^ (now >> 16) ^ counter)` over a process-local
975        // counter that every new process starts at 0 degenerates to a pure
976        // function of the current second. Three concurrent `lev run`
977        // invocations all mint `fetcher-1785127214-8b48` and silently share
978        // one run directory. A fresh process has no state to vary, so the
979        // property that has to hold is: IDs that share a timestamp still differ.
980        let ids: Vec<String> = (0..200).map(|_| new_run_id("same-agent")).collect();
981        let mut by_second: std::collections::HashMap<&str, Vec<&str>> =
982            std::collections::HashMap::new();
983        for id in &ids {
984            let (secs, suffix) = split_run_id(id);
985            by_second.entry(secs).or_default().push(suffix);
986        }
987        let mut largest = 0;
988        for (secs, suffixes) in &by_second {
989            let distinct: std::collections::HashSet<&&str> = suffixes.iter().collect();
990            assert_eq!(
991                distinct.len(),
992                suffixes.len(),
993                "two runs in second {secs} share a suffix: {suffixes:?}"
994            );
995            largest = largest.max(suffixes.len());
996        }
997        // 200 calls take microseconds, so they cannot all land in distinct
998        // seconds - without this the assertion above would be vacuous.
999        assert!(
1000            largest > 1,
1001            "expected IDs sharing a second, got {by_second:?}"
1002        );
1003    }
1004
1005    // ─── write_meta / read_meta roundtrip ───────────────────────────────────
1006
1007    #[test]
1008    fn write_and_read_meta_roundtrip() {
1009        // Isolated via `isolate_runs_dir_for_test` so write_meta/read_meta
1010        // never touch the real ~/.leviath/runs/ - the temp dir is removed
1011        // automatically when `_guard` drops, so no manual cleanup needed.
1012        with_isolated_runs_dir("write-and-read-meta-roundtrip", |_d| {
1013            let meta = RunMeta::new(
1014                "test-roundtrip-unit".into(),
1015                "test-agent".into(),
1016                "/agents/test".into(),
1017                "unit test".into(),
1018                Some("model-x".into()),
1019                "/tmp".into(),
1020                2,
1021            );
1022
1023            create_run(&meta).unwrap();
1024            let back = read_meta(&meta.run_id).unwrap();
1025            assert_eq!(back.run_id, "test-roundtrip-unit");
1026            assert_eq!(back.agent_name, "test-agent");
1027            assert_eq!(back.task, "unit test");
1028            assert_eq!(back.model.as_deref(), Some("model-x"));
1029        });
1030    }
1031
1032    #[test]
1033    fn read_meta_returns_err_on_corrupted_json() {
1034        // Exercises `read_meta_from`'s `serde_json::from_str(&json)?` Err
1035        // arm: a `meta.json` that exists but doesn't parse as a `RunMeta`.
1036        with_isolated_runs_dir("read-meta-returns-err-on-corrupted-json", |_d| {
1037            let run_id = "corrupted-meta-run";
1038            let dir = run_dir(run_id);
1039            std::fs::create_dir_all(&dir).unwrap();
1040            std::fs::write(dir.join("meta.json"), "not valid json").unwrap();
1041
1042            let result = read_meta(run_id);
1043            assert!(result.is_err());
1044        });
1045    }
1046
1047    // ─── write_stages_index / read_stages_index roundtrip ───────────────────
1048
1049    #[test]
1050    fn write_and_read_stages_index_roundtrip() {
1051        with_isolated_runs_dir("write-and-read-stages-index-roundtrip", |_d| {
1052            let run_id = "test-stages-idx-unit";
1053            let dir = run_dir(run_id);
1054            std::fs::create_dir_all(&dir).unwrap();
1055
1056            let stages = vec![
1057                StageRecord::new("init".into(), 0),
1058                StageRecord::new("process".into(), 1),
1059            ];
1060            write_stages_index(run_id, &stages).unwrap();
1061            let back = read_stages_index(run_id);
1062            assert_eq!(back.len(), 2);
1063            assert_eq!(back[0].name, "init");
1064            assert_eq!(back[1].name, "process");
1065        });
1066    }
1067
1068    #[test]
1069    fn read_stages_index_missing_returns_empty() {
1070        let back = read_stages_index("nonexistent-run-12345");
1071        assert!(back.is_empty());
1072    }
1073
1074    // ─── write/read context snapshot ────────────────────────────────────────
1075
1076    #[test]
1077    fn write_and_read_context_snapshot_roundtrip() {
1078        with_isolated_runs_dir("write-and-read-context-snapshot-roundtrip", |_d| {
1079            let run_id = "test-ctx-snap-unit";
1080            let dir = run_dir(run_id);
1081            std::fs::create_dir_all(&dir).unwrap();
1082
1083            let snap = ContextSnapshot {
1084                stage_name: "test".into(),
1085                total_tokens: 42,
1086                max_tokens: 8192,
1087                regions: vec![],
1088            };
1089            write_context_snapshot(run_id, &snap).unwrap();
1090            let back = read_context_snapshot(run_id).unwrap();
1091            assert_eq!(back.stage_name, "test");
1092            assert_eq!(back.total_tokens, 42);
1093        });
1094    }
1095
1096    #[test]
1097    fn read_context_snapshot_missing_returns_none() {
1098        assert!(read_context_snapshot("nonexistent-ctx-run").is_none());
1099    }
1100
1101    #[test]
1102    fn read_run_archive_roundtrips_and_context_history_replays() {
1103        with_isolated_runs_dir("read-run-archive-roundtrip", |_d| {
1104            use leviath_core::run_archive::{self, RunIdentity, RunRecord};
1105            let run_id = "archive-unit";
1106            std::fs::create_dir_all(run_dir(run_id)).unwrap();
1107            let mut buf = Vec::new();
1108            run_archive::write_archive_start(&mut buf, run_archive::RUN_ARCHIVE_VERSION).unwrap();
1109            let meta = RunMeta::new(
1110                run_id.to_string(),
1111                "a".to_string(),
1112                "/p".to_string(),
1113                "t".to_string(),
1114                None,
1115                "/w".to_string(),
1116                1,
1117            );
1118            run_archive::write_record(
1119                &mut buf,
1120                &RunRecord::Header {
1121                    identity: RunIdentity {
1122                        run_id: run_id.to_string(),
1123                        machine_id: "m".to_string(),
1124                        world_id: "w".to_string(),
1125                        created_at: 0,
1126                    },
1127                    meta: Box::new(meta),
1128                },
1129            )
1130            .unwrap();
1131            run_archive::write_record(
1132                &mut buf,
1133                &RunRecord::ContextCheckpoint {
1134                    snapshot: ContextSnapshot {
1135                        stage_name: "plan".to_string(),
1136                        total_tokens: 3,
1137                        max_tokens: 100,
1138                        regions: vec![],
1139                    },
1140                    at: 1,
1141                },
1142            )
1143            .unwrap();
1144            std::fs::write(run_dir(run_id).join("run.lvr"), &buf).unwrap();
1145
1146            let records = read_run_archive(run_id).expect("archive read");
1147            assert_eq!(records.len(), 2);
1148            let history = context_history(run_id);
1149            assert_eq!(history.len(), 1);
1150            assert_eq!(history[0].context.stage_name, "plan");
1151        });
1152    }
1153
1154    #[test]
1155    fn read_run_archive_missing_or_corrupt_returns_none() {
1156        with_isolated_runs_dir("read-run-archive-corrupt", |_d| {
1157            // Missing archive.
1158            assert!(read_run_archive("no-such-archive-run").is_none());
1159            assert!(context_history("no-such-archive-run").is_empty());
1160            // Corrupt archive (bad magic) → None, not a panic.
1161            let run_id = "corrupt-archive-unit";
1162            std::fs::create_dir_all(run_dir(run_id)).unwrap();
1163            std::fs::write(run_dir(run_id).join("run.lvr"), b"not an archive").unwrap();
1164            assert!(read_run_archive(run_id).is_none());
1165            assert!(context_history(run_id).is_empty());
1166        });
1167    }
1168
1169    // ─── stage_dir / append_stage_output / append_stage_log ─────────────────
1170
1171    #[test]
1172    fn stage_dir_path_structure() {
1173        let path = stage_dir("run-abc", 2);
1174        assert!(path.ends_with("stages/2"));
1175        assert!(path.to_str().unwrap().contains("run-abc"));
1176    }
1177
1178    #[test]
1179    fn append_and_tail_stage_output() {
1180        with_isolated_runs_dir("append-and-tail-stage-output", |_d| {
1181            let run_id = "test-stage-output-unit";
1182            append_stage_output(run_id, 0, "line 1");
1183            append_stage_output(run_id, 0, "line 2");
1184            let output = tail_stage_output(run_id, 0, 4096);
1185            assert!(output.contains("line 1"));
1186            assert!(output.contains("line 2"));
1187        });
1188    }
1189
1190    #[test]
1191    fn append_and_tail_stage_log() {
1192        with_isolated_runs_dir("append-and-tail-stage-log", |_d| {
1193            let run_id = "test-stage-log-unit";
1194            append_stage_log(run_id, 0, "event A");
1195            append_stage_log(run_id, 0, "event B");
1196            let log = tail_stage_log(run_id, 0, 4096);
1197            assert!(log.contains("event A"));
1198            assert!(log.contains("event B"));
1199        });
1200    }
1201
1202    // ─── write/read stage context ───────────────────────────────────────────
1203
1204    #[test]
1205    fn write_and_read_stage_context_roundtrip() {
1206        with_isolated_runs_dir("write-and-read-stage-context-roundtrip", |_d| {
1207            let run_id = "test-stage-ctx-unit";
1208            let snap = ContextSnapshot {
1209                stage_name: "stage-0".into(),
1210                total_tokens: 100,
1211                max_tokens: 4096,
1212                regions: vec![],
1213            };
1214            write_stage_context(run_id, 0, &snap).unwrap();
1215            let back = read_stage_context(run_id, 0).unwrap();
1216            assert_eq!(back.stage_name, "stage-0");
1217        });
1218    }
1219
1220    #[test]
1221    fn read_stage_context_missing_returns_none() {
1222        assert!(read_stage_context("nonexistent-run", 99).is_none());
1223    }
1224
1225    // ─── append_dashboard_log ─────────────────────────────────────────────
1226
1227    #[test]
1228    fn append_dashboard_log_creates_log_file() {
1229        with_isolated_runs_dir("append-dashboard-log-creates-log-file", |_d| {
1230            append_dashboard_log("coverage-test-message");
1231            assert!(dashboard_log_path().exists());
1232        });
1233    }
1234
1235    #[test]
1236    fn append_dashboard_log_open_failure_is_silently_ignored() {
1237        // Covers the `if let Ok(mut file) = ... .open(&path)` pattern *not*
1238        // matching: pre-create the resolved log path as a directory, so
1239        // opening it for append fails with `IsADirectory` - the function
1240        // must swallow this silently (best-effort logging) rather than
1241        // panic.
1242        with_isolated_runs_dir("append-dashboard-log-open-failure", |_d| {
1243            let path = dashboard_log_path();
1244            std::fs::create_dir_all(&path).unwrap();
1245            append_dashboard_log("this should not panic");
1246            assert!(path.is_dir());
1247        });
1248    }
1249
1250    #[test]
1251    fn append_dashboard_log_path_with_no_parent_skips_create_dir_all() {
1252        // Every other test resolves `dashboard_log_path()` to a path with a
1253        // real parent component, leaving the `if let Some(parent) = ...`
1254        // pattern's `None` arm (root paths like "/" have no parent) never
1255        // exercised. `temp_env::with_var` points the override at "/" for the
1256        // closure's duration (serialized process-wide, then restored).
1257        temp_env::with_var("LEVIATH_DASHBOARD_LOG_PATH", Some("/"), || {
1258            assert!(dashboard_log_path().parent().is_none());
1259            append_dashboard_log("this should not panic even with no parent");
1260        });
1261    }
1262
1263    #[test]
1264    fn dashboard_log_rolls_once_over_cap() {
1265        // A tiny cap so a couple of lines trips the roll. The over-cap live file
1266        // is moved to `<name>.1` and a fresh live file is started.
1267        let dir = tempfile::tempdir().unwrap();
1268        let path = dir.path().join("dashboard.log");
1269        append_dashboard_log_capped(&path, "first line well over the tiny cap", 8);
1270        // First write created the file; it now exceeds the 8-byte cap.
1271        assert!(path.exists());
1272        assert!(!rolled_log_path(&path).exists());
1273        // Second write sees the file over cap → rolls it and restarts.
1274        append_dashboard_log_capped(&path, "second", 8);
1275        let rolled = rolled_log_path(&path);
1276        assert!(rolled.exists(), "previous generation rolled to <name>.1");
1277        assert!(
1278            std::fs::read_to_string(&rolled)
1279                .unwrap()
1280                .contains("first line")
1281        );
1282        // The live file was restarted with only the newest line.
1283        let live = std::fs::read_to_string(&path).unwrap();
1284        assert!(live.contains("second"));
1285        assert!(!live.contains("first line"));
1286    }
1287
1288    #[test]
1289    fn dashboard_log_does_not_roll_under_cap() {
1290        let dir = tempfile::tempdir().unwrap();
1291        let path = dir.path().join("dashboard.log");
1292        append_dashboard_log_capped(&path, "a", 1_000_000);
1293        append_dashboard_log_capped(&path, "b", 1_000_000);
1294        // Both lines are in the single live file; nothing was rolled.
1295        assert!(!rolled_log_path(&path).exists());
1296        let live = std::fs::read_to_string(&path).unwrap();
1297        assert!(live.contains("a") && live.contains("b"));
1298    }
1299
1300    // ─── dashboard_log_path ────────────────────────────────────────────────
1301
1302    #[test]
1303    fn dashboard_log_path_structure() {
1304        // Exercises the real (env-reading) `dashboard_log_path()` on its
1305        // fallback branch, so - like `runs_dir_structure` below - it forces
1306        // `LEVIATH_DASHBOARD_LOG_PATH` unset via `temp_env::with_var_unset`,
1307        // which also serializes against every other temp-env test so a
1308        // concurrently-isolated test can't race this assertion.
1309        temp_env::with_var_unset("LEVIATH_DASHBOARD_LOG_PATH", || {
1310            let path = dashboard_log_path();
1311            assert!(path.to_str().unwrap().contains(".leviath"));
1312            assert!(path.to_str().unwrap().ends_with("dashboard.log"));
1313        });
1314    }
1315
1316    /// With no `LEVIATH_DASHBOARD_LOG_PATH`, the dashboard log must follow
1317    /// `LEVIATH_HOME` like every other data path. Resolving through the raw
1318    /// OS home would leave a fully isolated test session still appending to
1319    /// the developer's real `~/.leviath/dashboard.log`.
1320    #[test]
1321    fn dashboard_log_path_honors_leviath_home() {
1322        temp_env::with_vars(
1323            [
1324                ("LEVIATH_DASHBOARD_LOG_PATH", None),
1325                ("LEVIATH_HOME", Some("/custom/home")),
1326            ],
1327            || {
1328                assert_eq!(
1329                    dashboard_log_path(),
1330                    PathBuf::from("/custom/home/.leviath/dashboard.log")
1331                );
1332            },
1333        );
1334    }
1335
1336    // ─── runs_dir / run_dir ────────────────────────────────────────────────
1337
1338    #[test]
1339    fn runs_dir_structure() {
1340        // See the comment on `dashboard_log_path_structure` above - same
1341        // race, same fix, for `LEVIATH_RUNS_DIR`.
1342        temp_env::with_var_unset("LEVIATH_RUNS_DIR", || {
1343            let path = runs_dir();
1344            assert!(path.to_str().unwrap().contains(".leviath"));
1345            assert!(path.to_str().unwrap().ends_with("runs"));
1346        });
1347    }
1348
1349    #[test]
1350    fn runs_dir_from_uses_override_when_provided() {
1351        let path = runs_dir_from(Some("/custom/leviath/runs"));
1352        assert_eq!(path, PathBuf::from("/custom/leviath/runs"));
1353    }
1354
1355    #[test]
1356    fn runs_dir_from_falls_back_to_home_when_none() {
1357        let path = runs_dir_from(None);
1358        #[cfg(unix)]
1359        assert!(path.ends_with(".leviath/runs"));
1360        #[cfg(windows)]
1361        assert!(path.ends_with(".leviath\\runs"));
1362    }
1363
1364    /// With no `LEVIATH_RUNS_DIR`, the runs dir must follow `LEVIATH_HOME` - the
1365    /// same home every other leviath path resolves through. Without this, setting
1366    /// `LEVIATH_HOME` isolates a test's config/socket/agents dir while its runs
1367    /// still land in the real `~/.leviath/runs`.
1368    #[test]
1369    fn runs_dir_follows_leviath_home() {
1370        temp_env::with_vars(
1371            [
1372                ("LEVIATH_RUNS_DIR", None::<&str>),
1373                ("LEVIATH_HOME", Some("/tmp/leviath-home-runs-test")),
1374            ],
1375            || {
1376                assert_eq!(
1377                    runs_dir(),
1378                    PathBuf::from("/tmp/leviath-home-runs-test")
1379                        .join(".leviath")
1380                        .join("runs")
1381                );
1382            },
1383        );
1384    }
1385
1386    #[test]
1387    fn dashboard_log_path_from_uses_override_when_provided() {
1388        let path = dashboard_log_path_from(Some("/custom/leviath/dashboard.log"));
1389        assert_eq!(path, PathBuf::from("/custom/leviath/dashboard.log"));
1390    }
1391
1392    #[test]
1393    fn dashboard_log_path_from_falls_back_to_home_when_none() {
1394        let path = dashboard_log_path_from(None);
1395        #[cfg(unix)]
1396        assert!(path.ends_with(".leviath/dashboard.log"));
1397        #[cfg(windows)]
1398        assert!(path.ends_with(".leviath\\dashboard.log"));
1399    }
1400
1401    #[test]
1402    fn run_dir_contains_run_id() {
1403        let path = run_dir("my-run-123");
1404        assert!(path.to_str().unwrap().contains("my-run-123"));
1405    }
1406
1407    // ─── with_isolated_runs_dir ─────────────────────────────────────────────
1408
1409    #[test]
1410    fn with_isolated_runs_dir_points_at_temp_dir_and_cleans_up_after() {
1411        // Deliberately avoids a racy before/after ambient comparison (a
1412        // concurrently-isolated test could own `LEVIATH_RUNS_DIR` just before
1413        // or after this closure's temp-env window): instead assert the helper's
1414        // own hash-derived path is live *inside* the closure and removed
1415        // afterward - a property no other test can perturb, since none
1416        // produces this exact path.
1417        let inside = with_isolated_runs_dir("helper-self-test", |base_dir| {
1418            let expected = base_dir.join("runs");
1419            assert_eq!(runs_dir(), expected);
1420            assert!(runs_dir().exists());
1421            assert_eq!(dashboard_log_path(), base_dir.join("dashboard.log"));
1422            expected
1423        });
1424        // Closure returned: the temp dir the helper created is gone.
1425        assert!(!inside.exists());
1426    }
1427
1428    // ─── tail_file edge cases ──────────────────────────────────────────────
1429
1430    #[test]
1431    fn tail_file_exact_size() {
1432        let dir = tempfile::tempdir().unwrap();
1433        let path = dir.path().join("exact.txt");
1434        std::fs::write(&path, "exactly").unwrap();
1435        // max_bytes == file size
1436        let result = tail_file(&path, 7);
1437        assert_eq!(result, "exactly");
1438    }
1439
1440    #[test]
1441    fn tail_file_tail_without_newline_returns_whole_window() {
1442        // When the last `max_bytes` window of a larger file contains no '\n'
1443        // at all (a single long line with no line breaks), `tail_file` cannot
1444        // skip to a newline boundary, so it falls through to the `else` arm and
1445        // returns the whole (newline-free) tail window verbatim. Bytes are
1446        // written raw (never via `writeln!`, which would append '\n') so that
1447        // on *every* OS the tail slice is guaranteed newline-free - on Windows
1448        // ordinary text output is `\r\n`-terminated, which would otherwise keep
1449        // a '\n' in the window and take the `if` arm instead.
1450        let dir = tempfile::tempdir().unwrap();
1451        let path = dir.path().join("no_newline.txt");
1452        // 100 raw bytes, no newline anywhere.
1453        let content = "a".repeat(100);
1454        std::fs::write(&path, content.as_bytes()).unwrap();
1455        // A 10-byte window is smaller than the file (100) and contains no '\n'.
1456        let result = tail_file(&path, 10);
1457        assert_eq!(result, "aaaaaaaaaa");
1458    }
1459
1460    // ─── RunMeta metadata and callback_url ─────────────────────────────────
1461
1462    #[test]
1463    fn run_meta_with_metadata() {
1464        let mut meta = RunMeta::new(
1465            "meta-run".into(),
1466            "agent".into(),
1467            "/p".into(),
1468            "task".into(),
1469            None,
1470            "/w".into(),
1471            1,
1472        );
1473        meta.metadata
1474            .insert("key1".to_string(), "value1".to_string());
1475        meta.callback_url = Some("https://example.com/hook".to_string());
1476        meta.parent_run_id = Some("parent-123".to_string());
1477
1478        let json = serde_json::to_string(&meta).unwrap();
1479        let back: RunMeta = serde_json::from_str(&json).unwrap();
1480        assert_eq!(back.metadata.get("key1").unwrap(), "value1");
1481        assert_eq!(
1482            back.callback_url.as_deref(),
1483            Some("https://example.com/hook")
1484        );
1485        assert_eq!(back.parent_run_id.as_deref(), Some("parent-123"));
1486    }
1487
1488    // ─── StageRecord modifications ─────────────────────────────────────────
1489
1490    #[test]
1491    fn stage_record_mutation() {
1492        let mut rec = StageRecord::new("test".into(), 0);
1493        rec.status = StageRunStatus::Active;
1494        rec.started_at = Some(1000);
1495        rec.prompt_tokens = 500;
1496        rec.completion_tokens = 200;
1497        rec.cached_tokens = 50;
1498
1499        assert_eq!(rec.status, StageRunStatus::Active);
1500        assert_eq!(rec.started_at, Some(1000));
1501        assert_eq!(rec.prompt_tokens, 500);
1502        assert_eq!(rec.completion_tokens, 200);
1503        assert_eq!(rec.cached_tokens, 50);
1504
1505        rec.status = StageRunStatus::Complete;
1506        rec.ended_at = Some(2000);
1507        assert_eq!(rec.status, StageRunStatus::Complete);
1508        assert_eq!(rec.ended_at, Some(2000));
1509    }
1510
1511    // ─── ContextSnapshot with entries ──────────────────────────────────────
1512
1513    #[test]
1514    fn context_snapshot_with_entries() {
1515        let snap = ContextSnapshot {
1516            stage_name: "main".into(),
1517            total_tokens: 1000,
1518            max_tokens: 8192,
1519            regions: vec![
1520                RegionSnapshot {
1521                    name: "system".into(),
1522                    kind: "pinned".into(),
1523                    current_tokens: 100,
1524                    max_tokens: 2000,
1525                    entries: vec![
1526                        RegionEntrySnapshot {
1527                            content: "You are helpful".into(),
1528                            tokens: 3,
1529                            kind: Default::default(),
1530                            metadata: None,
1531                            key: None,
1532                            taint: Default::default(),
1533                        },
1534                        RegionEntrySnapshot {
1535                            content: "Additional instruction".into(),
1536                            tokens: 5,
1537                            kind: Default::default(),
1538                            metadata: Some(serde_json::json!({"source": "user"})),
1539                            key: None,
1540                            taint: Default::default(),
1541                        },
1542                    ],
1543                },
1544                RegionSnapshot {
1545                    name: "conversation".into(),
1546                    kind: "sliding".into(),
1547                    current_tokens: 900,
1548                    max_tokens: 6000,
1549                    entries: vec![],
1550                },
1551            ],
1552        };
1553
1554        let json = serde_json::to_string_pretty(&snap).unwrap();
1555        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
1556        assert_eq!(back.regions.len(), 2);
1557        assert_eq!(back.regions[0].entries.len(), 2);
1558        assert_eq!(back.regions[0].entries[1].tokens, 5);
1559        assert!(back.regions[0].entries[1].metadata.is_some());
1560    }
1561
1562    // ─── RegionEntrySnapshot metadata ──────────────────────────────────────
1563
1564    #[test]
1565    fn region_entry_snapshot_metadata_omitted_when_none() {
1566        let entry = RegionEntrySnapshot {
1567            content: "test".into(),
1568            tokens: 1,
1569            kind: Default::default(),
1570            metadata: None,
1571            key: None,
1572            taint: Default::default(),
1573        };
1574        let json = serde_json::to_value(&entry).unwrap();
1575        assert!(json.get("metadata").is_none());
1576    }
1577
1578    // ─── Multiple stage output appends ─────────────────────────────────────
1579
1580    #[test]
1581    fn append_stage_output_multiple_stages() {
1582        with_isolated_runs_dir("append-stage-output-multiple-stages", |_d| {
1583            let run_id = "test-multi-stage-out";
1584            append_stage_output(run_id, 0, "stage 0 output");
1585            append_stage_output(run_id, 1, "stage 1 output");
1586            append_stage_output(run_id, 2, "stage 2 output");
1587
1588            let out0 = tail_stage_output(run_id, 0, 4096);
1589            let out1 = tail_stage_output(run_id, 1, 4096);
1590            let out2 = tail_stage_output(run_id, 2, 4096);
1591
1592            assert!(out0.contains("stage 0 output"));
1593            assert!(out1.contains("stage 1 output"));
1594            assert!(out2.contains("stage 2 output"));
1595            // Verify no cross-contamination
1596            assert!(!out0.contains("stage 1 output"));
1597        });
1598    }
1599
1600    // ─── list_runs ─────────────────────────────────────────────────────────
1601
1602    #[test]
1603    fn list_runs_returns_sorted() {
1604        with_isolated_runs_dir("list-runs-returns-sorted", |_d| {
1605            let meta1 = RunMeta::new(
1606                "test-list-run-a".into(),
1607                "agent".into(),
1608                "/p".into(),
1609                "task a".into(),
1610                None,
1611                "/w".into(),
1612                1,
1613            );
1614            let meta2 = RunMeta::new(
1615                "test-list-run-b".into(),
1616                "agent".into(),
1617                "/p".into(),
1618                "task b".into(),
1619                None,
1620                "/w".into(),
1621                1,
1622            );
1623
1624            let _ = create_run(&meta1);
1625            // Small delay to ensure different timestamps
1626            let _ = create_run(&meta2);
1627
1628            let runs = list_runs();
1629            // Both should appear in the list
1630            let ids: Vec<&str> = runs.iter().map(|r| r.run_id.as_str()).collect();
1631            assert!(ids.contains(&"test-list-run-a"));
1632            assert!(ids.contains(&"test-list-run-b"));
1633        });
1634    }
1635
1636    // ─── tail_stage_log / tail_stage_output empty ──────────────────────────
1637
1638    #[test]
1639    fn tail_stage_output_nonexistent_returns_empty() {
1640        assert_eq!(tail_stage_output("no-such-run-xyz", 0, 4096), "");
1641    }
1642
1643    #[test]
1644    fn tail_stage_log_nonexistent_returns_empty() {
1645        assert_eq!(tail_stage_log("no-such-run-xyz", 0, 4096), "");
1646    }
1647
1648    // ─── list_runs_in_dir ───────────────────────────────────────────────────
1649
1650    #[test]
1651    fn list_runs_in_dir_nonexistent_returns_empty() {
1652        let result = list_runs_in_dir(PathBuf::from("/nonexistent/leviath/runs/coverage-test"));
1653        assert!(result.is_empty());
1654    }
1655
1656    #[test]
1657    fn list_runs_in_dir_empty_dir_returns_empty() {
1658        let dir = tempfile::tempdir().unwrap();
1659        let result = list_runs_in_dir(dir.path().to_path_buf());
1660        assert!(result.is_empty());
1661    }
1662
1663    #[test]
1664    fn list_runs_in_dir_unreadable_dir_returns_empty() {
1665        // Covers the `if let Ok(entries) = std::fs::read_dir(&dir)` pattern
1666        // *not* matching: `dir.exists()` is true (so the earlier early-return
1667        // is skipped) but `read_dir` fails, so the whole block is silently
1668        // skipped. Pointing at a *file* makes `read_dir` fail on every platform.
1669        let dir = tempfile::tempdir().unwrap();
1670        let not_a_dir = dir.path().join("runs-is-a-file");
1671        std::fs::write(&not_a_dir, "not a dir").unwrap();
1672        let result = list_runs_in_dir(not_a_dir);
1673        assert!(result.is_empty());
1674    }
1675
1676    #[test]
1677    fn append_stage_output_open_failure_is_silently_skipped() {
1678        // When `output.log` already exists as a *directory*, `OpenOptions::open`
1679        // fails and the write is silently skipped (the `if let Ok(file)` false
1680        // path). Making the target a directory fails the open on every platform.
1681        crate::runstate::with_isolated_runs_dir("append_stage_output_open_failure", |_d| {
1682            let run_id = "append-out-openfail";
1683            ensure_stage_dir(run_id, 0);
1684            std::fs::create_dir_all(stage_dir(run_id, 0).join("output.log")).unwrap();
1685            append_stage_output(run_id, 0, "ignored"); // must not panic
1686        });
1687    }
1688
1689    #[test]
1690    fn append_stage_log_open_failure_is_silently_skipped() {
1691        // Same as above for `logs.log` in `append_stage_log`.
1692        crate::runstate::with_isolated_runs_dir("append_stage_log_open_failure", |_d| {
1693            let run_id = "append-log-openfail";
1694            ensure_stage_dir(run_id, 0);
1695            std::fs::create_dir_all(stage_dir(run_id, 0).join("logs.log")).unwrap();
1696            append_stage_log(run_id, 0, "ignored"); // must not panic
1697        });
1698    }
1699
1700    // ─── runs_dir / list_runs edge cases ────────────────────────────────────
1701
1702    #[test]
1703    fn runs_dir_with_override_set_returns_override() {
1704        let tmpdir = tempfile::tempdir().unwrap();
1705        temp_env::with_var("LEVIATH_RUNS_DIR", Some(tmpdir.path()), || {
1706            assert_eq!(runs_dir(), tmpdir.path());
1707        });
1708    }
1709
1710    #[test]
1711    fn runs_dir_without_override_falls_back_to_home() {
1712        temp_env::with_var_unset("LEVIATH_RUNS_DIR", || {
1713            let dir = runs_dir();
1714            #[cfg(unix)]
1715            assert!(dir.ends_with(".leviath/runs"));
1716            #[cfg(windows)]
1717            assert!(dir.ends_with(".leviath\\runs"));
1718        });
1719    }
1720
1721    #[test]
1722    fn list_runs_empty_when_runs_dir_missing_or_empty() {
1723        // Isolated via `isolate_runs_dir_for_test`, so this is a genuinely
1724        // empty runs dir (not "the real dir, which we hope has no entry with
1725        // this exact bogus id") - can assert real emptiness instead of just
1726        // absence of one specific id.
1727        with_isolated_runs_dir("list-runs-empty-when-runs-dir-missing-or-empty", |_d| {
1728            let runs = list_runs();
1729            assert!(runs.is_empty());
1730        });
1731    }
1732
1733    #[test]
1734    fn tail_file_nonexistent_path_returns_empty() {
1735        let path = std::path::Path::new("/nonexistent/path/to/a/file.log");
1736        assert_eq!(tail_file(path, 1024), "");
1737    }
1738
1739    #[test]
1740    fn tail_file_small_file_returns_whole_contents() {
1741        let dir = tempfile::tempdir().unwrap();
1742        let path = dir.path().join("small.log");
1743        std::fs::write(&path, "hello world").unwrap();
1744        assert_eq!(tail_file(&path, 1024), "hello world");
1745    }
1746
1747    #[test]
1748    fn tail_file_large_file_truncates_from_offset() {
1749        let dir = tempfile::tempdir().unwrap();
1750        let path = dir.path().join("big.log");
1751        let content = "a".repeat(100) + "\nTAIL_MARKER\n";
1752        std::fs::write(&path, &content).unwrap();
1753        let tailed = tail_file(&path, 20);
1754        assert!(tailed.contains("TAIL_MARKER"));
1755        assert!(tailed.len() < content.len());
1756    }
1757
1758    #[test]
1759    fn tail_file_directory_path_returns_empty() {
1760        // metadata() and File::open() both succeed on a directory (confirmed
1761        // empirically on macOS/Linux); it's read_to_end() that fails with
1762        // "Is a directory" - and that error is deliberately discarded (`let
1763        // _ = file.read_to_end(&mut buf);`), so this exercises the
1764        // graceful-empty-buffer fallback at the bottom of the function, not
1765        // either of the two `Err(_) => return String::new()` early returns.
1766        let dir = tempfile::tempdir().unwrap();
1767        assert_eq!(tail_file(dir.path(), 4), "");
1768    }
1769
1770    #[cfg(unix)]
1771    #[test]
1772    fn tail_file_open_permission_denied_returns_empty() {
1773        // A file with no permissions at all: `Path::exists()`/`fs::metadata()`
1774        // only need search (execute) permission on the *parent* directories
1775        // to stat a path, not read permission on the file itself - so both
1776        // succeed here. `std::fs::File::open()` in read mode, however,
1777        // genuinely fails with `PermissionDenied`. Unlike the metadata-error
1778        // arm (only reachable via a delete-between-calls race), this is a
1779        // deterministic way to exercise the `File::open` `Err(_)` arm.
1780        use std::os::unix::fs::PermissionsExt;
1781
1782        let dir = tempfile::tempdir().unwrap();
1783        let path = dir.path().join("no-permissions.log");
1784        // Content must exceed max_bytes so the "whole file" fast path
1785        // (`file_size <= max_bytes`) doesn't short-circuit before reaching
1786        // the `File::open` call under test.
1787        std::fs::write(&path, "x".repeat(100)).unwrap();
1788        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
1789
1790        assert_eq!(tail_file(&path, 4), "");
1791
1792        // Restore permissions so the tempdir can clean itself up on drop.
1793        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1794    }
1795
1796    // ─── hermetic write/read coverage tests (use _to/_from/_in helpers) ───────
1797
1798    #[test]
1799    fn write_context_snapshot_to_hermetic() {
1800        let dir = tempfile::tempdir().unwrap();
1801        let snap = ContextSnapshot {
1802            stage_name: "cov-stage".into(),
1803            total_tokens: 42,
1804            max_tokens: 8192,
1805            regions: vec![],
1806        };
1807        write_context_snapshot_to(dir.path(), &snap).unwrap();
1808        let json = std::fs::read_to_string(dir.path().join("context.json")).unwrap();
1809        let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
1810        assert_eq!(back.total_tokens, 42);
1811    }
1812
1813    #[test]
1814    fn write_context_snapshot_to_fails_without_dir() {
1815        let snap = ContextSnapshot {
1816            stage_name: "s".into(),
1817            total_tokens: 1,
1818            max_tokens: 100,
1819            regions: vec![],
1820        };
1821        let nonexistent = std::path::Path::new("/nonexistent-cov-dir-xyzzy-abc");
1822        let result = write_context_snapshot_to(nonexistent, &snap);
1823        assert!(result.is_err());
1824    }
1825
1826    #[test]
1827    fn write_context_snapshot_to_fails_when_rename_target_is_a_dir() {
1828        // Covers the `std::fs::rename(&tmp, &path)?` `Err` arm: the tmp file
1829        // write succeeds (its directory is writable), but the final rename
1830        // fails because `context.json` already exists as a *directory* --
1831        // `rename(2)` on POSIX refuses to replace a directory with a
1832        // regular file, unlike a plain overwrite of an existing file.
1833        let dir = tempfile::tempdir().unwrap();
1834        std::fs::create_dir(dir.path().join("context.json")).unwrap();
1835        let snap = ContextSnapshot {
1836            stage_name: "s".into(),
1837            total_tokens: 1,
1838            max_tokens: 100,
1839            regions: vec![],
1840        };
1841        let result = write_context_snapshot_to(dir.path(), &snap);
1842        assert!(result.is_err());
1843    }
1844
1845    #[test]
1846    fn create_run_in_hermetic() {
1847        let tmpdir = tempfile::tempdir().unwrap();
1848        let run_dir = tmpdir.path().join("cov-run");
1849        let meta = RunMeta::new(
1850            "cov-run".into(),
1851            "cov-agent".into(),
1852            "/agents/cov".into(),
1853            "cov task".into(),
1854            None,
1855            "/tmp".into(),
1856            1,
1857        );
1858        create_run_in(&run_dir, &meta).unwrap();
1859        let back = read_meta_from(&run_dir).unwrap();
1860        assert_eq!(back.run_id, "cov-run");
1861    }
1862
1863    #[test]
1864    fn create_run_in_fails_on_bad_parent() {
1865        // A hardcoded "/nonexistent-.../run" path isn't reliably bad across
1866        // platforms: on Windows CI runners (which typically have write
1867        // access to create directories at the drive root), that path
1868        // resolves under the current drive's root and create_dir_all
1869        // actually succeeds there, while on Unix it fails because writing
1870        // to the real filesystem root needs privileges the CI user lacks --
1871        // this passed locally but failed on Windows CI. Use a path with a
1872        // regular file as a parent component instead: create_dir_all can
1873        // never succeed under a file, on any platform or set of permissions.
1874        let dir = tempfile::tempdir().unwrap();
1875        let not_a_dir = dir.path().join("not-a-directory");
1876        std::fs::write(&not_a_dir, "x").unwrap();
1877        let bad = not_a_dir.join("run");
1878        let meta = RunMeta::new(
1879            "run".into(),
1880            "a".into(),
1881            "/".into(),
1882            "t".into(),
1883            None,
1884            "/tmp".into(),
1885            1,
1886        );
1887        let result = create_run_in(&bad, &meta);
1888        assert!(result.is_err());
1889    }
1890
1891    #[test]
1892    fn write_meta_to_hermetic() {
1893        let tmpdir = tempfile::tempdir().unwrap();
1894        let meta = RunMeta::new(
1895            "cov-write-meta".into(),
1896            "a".into(),
1897            "/".into(),
1898            "t".into(),
1899            None,
1900            "/tmp".into(),
1901            1,
1902        );
1903        write_meta_to(tmpdir.path(), &meta).unwrap();
1904        let back = read_meta_from(tmpdir.path()).unwrap();
1905        assert_eq!(back.run_id, "cov-write-meta");
1906    }
1907
1908    #[test]
1909    fn write_meta_to_fails_without_dir() {
1910        let meta = RunMeta::new(
1911            "cov-no-dir".into(),
1912            "a".into(),
1913            "/".into(),
1914            "t".into(),
1915            None,
1916            "/tmp".into(),
1917            1,
1918        );
1919        let bad = std::path::Path::new("/nonexistent-cov-write-meta-xyzzy");
1920        let result = write_meta_to(bad, &meta);
1921        assert!(result.is_err());
1922    }
1923
1924    #[test]
1925    fn write_meta_to_fails_when_rename_target_is_a_dir() {
1926        // See `write_context_snapshot_to_fails_when_rename_target_is_a_dir`:
1927        // same `std::fs::rename(&tmp_path, &final_path)?` `Err` arm, forced
1928        // by pre-creating `meta.json` as a directory.
1929        let dir = tempfile::tempdir().unwrap();
1930        std::fs::create_dir(dir.path().join("meta.json")).unwrap();
1931        let meta = RunMeta::new(
1932            "cov-rename-fail".into(),
1933            "a".into(),
1934            "/".into(),
1935            "t".into(),
1936            None,
1937            "/tmp".into(),
1938            1,
1939        );
1940        let result = write_meta_to(dir.path(), &meta);
1941        assert!(result.is_err());
1942    }
1943
1944    #[test]
1945    fn read_meta_from_fails_on_missing_file() {
1946        let tmpdir = tempfile::tempdir().unwrap();
1947        let result = read_meta_from(tmpdir.path());
1948        assert!(result.is_err());
1949    }
1950
1951    #[test]
1952    fn write_stages_index_to_hermetic() {
1953        let tmpdir = tempfile::tempdir().unwrap();
1954        let stages = vec![StageRecord::new("cov-stage".into(), 0)];
1955        write_stages_index_to(tmpdir.path(), &stages).unwrap();
1956        let json = std::fs::read_to_string(tmpdir.path().join("stages.json")).unwrap();
1957        let back: Vec<StageRecord> = serde_json::from_str(&json).unwrap();
1958        assert_eq!(back.len(), 1);
1959        assert_eq!(back[0].name, "cov-stage");
1960    }
1961
1962    #[test]
1963    fn write_stages_index_to_fails_without_dir() {
1964        let stages = vec![StageRecord::new("s".into(), 0)];
1965        let bad = std::path::Path::new("/nonexistent-cov-stages-xyzzy");
1966        let result = write_stages_index_to(bad, &stages);
1967        assert!(result.is_err());
1968    }
1969
1970    #[test]
1971    fn write_stages_index_to_fails_when_rename_target_is_a_dir() {
1972        // See `write_context_snapshot_to_fails_when_rename_target_is_a_dir`:
1973        // same `std::fs::rename(&tmp, &path)?` `Err` arm, forced by
1974        // pre-creating `stages.json` as a directory.
1975        let dir = tempfile::tempdir().unwrap();
1976        std::fs::create_dir(dir.path().join("stages.json")).unwrap();
1977        let stages = vec![StageRecord::new("s".into(), 0)];
1978        let result = write_stages_index_to(dir.path(), &stages);
1979        assert!(result.is_err());
1980    }
1981
1982    #[test]
1983    fn list_runs_in_dir_includes_valid_run() {
1984        let tmpdir = tempfile::tempdir().unwrap();
1985        let run_id = "cov-listed-run";
1986        let run_subdir = tmpdir.path().join(run_id);
1987        std::fs::create_dir_all(&run_subdir).unwrap();
1988        let meta = RunMeta::new(
1989            run_id.into(),
1990            "list-agent".into(),
1991            "/agents/list".into(),
1992            "list task".into(),
1993            None,
1994            "/tmp".into(),
1995            1,
1996        );
1997        let json = serde_json::to_string_pretty(&meta).unwrap();
1998        std::fs::write(run_subdir.join("meta.json"), &json).unwrap();
1999
2000        // list_runs_in_dir now reads meta.json directly from the dir, no env var needed
2001        let runs = list_runs_in_dir(tmpdir.path().to_path_buf());
2002        assert!(runs.iter().any(|r| r.run_id == run_id));
2003    }
2004
2005    #[test]
2006    fn list_runs_in_dir_skips_entry_with_corrupted_meta_json() {
2007        // Exercises the `if let Ok(meta) = serde_json::from_str::<RunMeta>(...)`
2008        // else arm: a subdirectory whose meta.json exists and is readable as
2009        // a string, but doesn't parse as a `RunMeta`, is silently skipped
2010        // rather than propagating an error.
2011        let tmpdir = tempfile::tempdir().unwrap();
2012        let good_run_id = "cov-listed-good-run";
2013        let bad_run_id = "cov-listed-corrupted-run";
2014
2015        let good_subdir = tmpdir.path().join(good_run_id);
2016        std::fs::create_dir_all(&good_subdir).unwrap();
2017        let meta = RunMeta::new(
2018            good_run_id.into(),
2019            "list-agent".into(),
2020            "/agents/list".into(),
2021            "list task".into(),
2022            None,
2023            "/tmp".into(),
2024            1,
2025        );
2026        let json = serde_json::to_string_pretty(&meta).unwrap();
2027        std::fs::write(good_subdir.join("meta.json"), &json).unwrap();
2028
2029        let bad_subdir = tmpdir.path().join(bad_run_id);
2030        std::fs::create_dir_all(&bad_subdir).unwrap();
2031        std::fs::write(bad_subdir.join("meta.json"), "not valid json").unwrap();
2032
2033        // A subdirectory with NO meta.json exercises the *other* skip branch:
2034        // the `if let Ok(json) = read_to_string(&meta_path)` else arm (the file
2035        // can't be read), distinct from the parse-fails arm above. Covering
2036        // both here keeps list_runs_in_dir at 100% on every OS deterministically.
2037        let no_meta_run_id = "cov-listed-no-meta-run";
2038        std::fs::create_dir_all(tmpdir.path().join(no_meta_run_id)).unwrap();
2039
2040        let runs = list_runs_in_dir(tmpdir.path().to_path_buf());
2041        assert!(runs.iter().any(|r| r.run_id == good_run_id));
2042        assert!(!runs.iter().any(|r| r.run_id == bad_run_id));
2043        assert!(!runs.iter().any(|r| r.run_id == no_meta_run_id));
2044    }
2045
2046    // ─── force_cancel_in: the floor under every kill path ───
2047
2048    /// Write a run dir with `status` and return its path.
2049    fn run_dir_with(base: &std::path::Path, run_id: &str, status: RunStatus) -> PathBuf {
2050        let dir = base.join(run_id);
2051        let meta = RunMeta {
2052            status,
2053            ..RunMeta::new(
2054                run_id.into(),
2055                "a".into(),
2056                "/p".into(),
2057                "t".into(),
2058                None,
2059                "/w".into(),
2060                1,
2061            )
2062        };
2063        create_run_in(&dir, &meta).unwrap();
2064        dir
2065    }
2066
2067    #[test]
2068    fn force_cancel_terminates_every_non_terminal_status() {
2069        let base = tempfile::tempdir().unwrap();
2070        for status in [
2071            RunStatus::Starting,
2072            RunStatus::Running,
2073            RunStatus::WaitingInput,
2074        ] {
2075            let dir = run_dir_with(base.path(), &format!("live-{status}"), status.clone());
2076            assert_eq!(force_cancel_in(&dir, 99), ForceCancelOutcome::Cancelled);
2077            let meta = read_meta_from(&dir).unwrap();
2078            assert_eq!(meta.status, RunStatus::Cancelled, "{status} is killable");
2079            assert_eq!(meta.updated_at, 99, "the cancel is stamped");
2080        }
2081    }
2082
2083    #[test]
2084    fn force_cancel_leaves_a_finished_run_alone() {
2085        let base = tempfile::tempdir().unwrap();
2086        for status in [
2087            RunStatus::Complete,
2088            RunStatus::CompleteInteractive,
2089            RunStatus::Error,
2090            RunStatus::Cancelled,
2091        ] {
2092            let dir = run_dir_with(base.path(), &format!("done-{status}"), status.clone());
2093            assert_eq!(
2094                force_cancel_in(&dir, 99),
2095                ForceCancelOutcome::AlreadyTerminal,
2096                "{status} is already finished"
2097            );
2098            assert_eq!(read_meta_from(&dir).unwrap().status, status);
2099        }
2100    }
2101
2102    #[test]
2103    fn force_cancel_reports_no_such_run_for_a_missing_directory() {
2104        let base = tempfile::tempdir().unwrap();
2105        let outcome = force_cancel_in(&base.path().join("ghost"), 99);
2106        assert_eq!(outcome, ForceCancelOutcome::NoSuchRun);
2107        assert!(!outcome.found_run(), "nothing to cancel");
2108    }
2109
2110    /// A run dir whose metadata can't be parsed still gets terminated. Such a run
2111    /// is skipped by `list_runs`, so leaving it alone makes it both invisible and
2112    /// permanent - the one state from which there is no way back.
2113    #[test]
2114    fn force_cancel_writes_a_record_over_unreadable_metadata() {
2115        let base = tempfile::tempdir().unwrap();
2116        let dir = base.path().join("corrupt-run");
2117        std::fs::create_dir_all(&dir).unwrap();
2118        std::fs::write(dir.join("meta.json"), "{ not json").unwrap();
2119
2120        assert_eq!(force_cancel_in(&dir, 99), ForceCancelOutcome::Cancelled);
2121        let meta = read_meta_from(&dir).expect("now parses");
2122        assert_eq!(meta.status, RunStatus::Cancelled);
2123        assert_eq!(meta.run_id, "corrupt-run", "recovered from the dir name");
2124        assert!(meta.error.is_some(), "records why it was synthesized");
2125    }
2126
2127    /// A directory that exists but can't be written still counts as "found" - the
2128    /// caller must not report "no such run" for a run that plainly exists.
2129    #[test]
2130    fn force_cancel_reports_a_write_failure_but_still_found_the_run() {
2131        crate::test_support::with_tracing(|| {
2132            let base = tempfile::tempdir().unwrap();
2133            let dir = base.path().join("blocked-run");
2134            std::fs::create_dir_all(&dir).unwrap();
2135            // A directory where `meta.json` must go: the rename can't succeed.
2136            std::fs::create_dir_all(dir.join("meta.json")).unwrap();
2137
2138            let outcome = force_cancel_in(&dir, 99);
2139            assert_eq!(outcome, ForceCancelOutcome::WriteFailed);
2140            assert!(outcome.found_run());
2141        });
2142    }
2143
2144    #[test]
2145    fn append_dashboard_log_writes_message() {
2146        // Exercises the create_dir_all branch and writeln! branch via a unique marker.
2147        with_isolated_runs_dir("append-dashboard-log-writes-message", |_d| {
2148            let unique = format!("cov-dashboard-log-{}", std::process::id());
2149            append_dashboard_log(&unique);
2150            let content = std::fs::read_to_string(dashboard_log_path()).unwrap_or_default();
2151            assert!(content.contains(&unique));
2152        });
2153    }
2154}