wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Where the logs go, and what keeps them from filling the disk.
//!
//! Everything this workspace produces that is worth reading after the fact —
//! an operation started from the workbench, a test session, the daemon's own
//! debug log — is written under ONE root, in a directory named for what
//! produced it:
//!
//! ```text
//! .wsb/disposable/logs/
//! ├── ops/2026-08-22/143012-test-run-3f9a2c7d.jsonl
//! ├── test/2026-08-22_14-30-12/…
//! └── daemon/wsb-2026-08-22T14-30-12.log
//! ```
//!
//! # Why files at all
//!
//! An operation's output used to live only in the server's memory: a ring of
//! the last few thousand lines, dropped when the operation aged out of history
//! and gone entirely when the daemon restarted. A page showing a build could
//! be navigated away from and back, and what came back was a truncated log or
//! nothing. The file is the record; memory is a cache of its tail.
//!
//! # Why one root
//!
//! Because the limit is a limit on a DIRECTORY. Logs written to three places
//! can only be bounded by three separate policies, and the two that nobody
//! wrote grow until a disk fills. One root, one sweep, one number in the
//! settings page.
//!
//! # What a sweep removes
//!
//! Whole ENTRIES, oldest first, until the total is under the limit. An entry
//! is one readable thing: one operation's log, one test session's directory,
//! one daemon log file — never half of one, because half a test session is a
//! directory that reads as a session which lost its failures. Anything being
//! written right now is protected and skipped; a sweep that deleted a running
//! build's log would take the output out from under the page watching it.

use crate::capped::{self, Entry};
pub use crate::capped::{human_bytes, EntryKind, RemovedEntry, SweepReport, Usage};
use crate::prelude::*;
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};

/// The default ceiling on the whole logs directory.
///
/// Two gigabytes: large enough that months of ordinary work never reach it —
/// the workspaces this was written against had accumulated 174 MB and 494 MB
/// of test sessions over a season — and small enough to be a rounding error on
/// any disk that can hold this workspace's build trees.
pub mod op_log;

pub const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;

/// The smallest limit that may be set.
///
/// A limit below one run's output would sweep away the log a page is showing
/// as fast as it is written, which reads as "the workbench loses output" and
/// not as "the limit is too small". 64 MiB is comfortably more than the
/// largest single run observed.
pub const MIN_MAX_BYTES: u64 = 64 * 1024 * 1024;

/// Where a workspace's logs are, relative to it.
const RELATIVE: &str = ".wsb/disposable/logs";

/// One kind of log, which is one directory under the root.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stream {
    /// One file per operation started from the workbench or the command line.
    Ops,
    /// One directory per test session.
    Test,
    /// The daemon's own tracing output.
    Daemon,
}

impl Stream {
    pub const ALL: [Stream; 3] = [Stream::Ops, Stream::Test, Stream::Daemon];

    /// The directory name, which is also the name the settings page shows.
    pub fn dir_name(self) -> &'static str {
        match self {
            Stream::Ops => "ops",
            Stream::Test => "test",
            Stream::Daemon => "daemon",
        }
    }

    /// What one entry of this stream IS — the unit a sweep removes.
    pub fn entry_kind(self) -> EntryKind {
        match self {
            // One operation, one file.
            Stream::Ops => EntryKind::File,
            // One session, one directory of per-suite logs.
            Stream::Test => EntryKind::Directory,
            // One run of the daemon, one file.
            Stream::Daemon => EntryKind::File,
        }
    }

    pub fn describe(self) -> &'static str {
        match self {
            Stream::Ops => "operations started from the workbench",
            Stream::Test => "test sessions",
            Stream::Daemon => "the daemon's own log",
        }
    }
}

/// The logs root for a workspace.
///
/// Logs stay with the worktree that produced them. Computed here rather than
/// passed in because the callers are a CLI command, a daemon and a sweeper
/// that runs on a timer, and a root that three of them agree on only by
/// convention is a root that moves for two of them when it moves for one.
pub fn root(workspace_root: &Path) -> PathBuf {
    workspace_root.join(RELATIVE)
}

/// The directory one stream writes into, created if it is not there.
pub fn stream_dir(logs_root: &Path, stream: Stream) -> PathBuf {
    logs_root.join(stream.dir_name())
}

/// Create a stream's directory, naming what failed.
pub fn ensure_stream_dir(logs_root: &Path, stream: Stream) -> Result<PathBuf, String> {
    let dir = stream_dir(logs_root, stream);
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("cannot create the log directory {}: {e}", dir.display()))?;
    Ok(dir)
}

/// The file one operation's output is written to.
///
/// Dated directory, then a name that starts with the time — so a listing is in
/// order, an operator can find "the build I ran after lunch" without opening
/// anything, and a day's worth of operations is one directory to look at or to
/// remove. The id is on the end because it is what makes the name unique and
/// what nobody reads.
pub fn op_log_path(
    logs_root: &Path,
    kind: &str,
    id: &uuid::Uuid,
    started_at: DateTime<Utc>,
) -> PathBuf {
    let local = started_at.with_timezone(&chrono::Local);
    let short = id.simple().to_string();
    stream_dir(logs_root, Stream::Ops)
        .join(local.format("%Y-%m-%d").to_string())
        .join(format!(
            "{}-{}-{}.jsonl",
            local.format("%H%M%S"),
            slug(kind),
            &short[..8.min(short.len())]
        ))
}

/// The op log belonging to `id`, found on disk.
///
/// The inverse of [`op_log_path`], and the reason it exists: a log outlives
/// the process that wrote it and the daemon that served it, but the only
/// places that remembered which file belonged to which operation were IN
/// MEMORY. After a restart the file was still there, still complete, and
/// unreachable — so reopening a finished operation showed a disconnected
/// stream rather than the output sitting on disk.
///
/// The name carries only the first eight characters of the id, which is what
/// makes a listing readable and is NOT enough to identify a log. Every
/// candidate is confirmed against the full id in its own header, so a
/// truncation collision resolves to the right file instead of the first one.
pub fn find_op_log(logs_root: &Path, id: &uuid::Uuid) -> Option<PathBuf> {
    let short = id.simple().to_string();
    let short = &short[..8.min(short.len())];
    let suffix = format!("-{short}.jsonl");

    let ops = stream_dir(logs_root, Stream::Ops);
    let days = std::fs::read_dir(&ops).ok()?;
    // Newest day first: an id is looked up because somebody is looking at it,
    // and the operation they mean is far likelier to be recent than not.
    let mut dirs: Vec<PathBuf> = days
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect();
    dirs.sort();
    for day in dirs.into_iter().rev() {
        let Ok(files) = std::fs::read_dir(&day) else {
            continue;
        };
        for file in files.flatten() {
            let path = file.path();
            if !path
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.ends_with(&suffix))
            {
                continue;
            }
            // The header is one line. Reading it is what makes the eight
            // characters in the name a hint rather than an answer.
            if let Ok((header, _)) = op_log::read_envelope(&path) {
                if &header.id == id {
                    return Some(path);
                }
            }
        }
    }
    None
}

/// A name safe in a path: lowercase, and nothing but letters, digits and dashes.
fn slug(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut last_dash = true;
    for ch in text.chars() {
        if ch.is_ascii_alphanumeric() {
            out.extend(ch.to_lowercase());
            last_dash = false;
        } else if !last_dash {
            out.push('-');
            last_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    if out.is_empty() {
        "op".to_string()
    } else {
        out
    }
}

/// Every entry under the root, in no particular order.
///
/// A stream directory that is not there yields nothing — a workspace that has
/// never run a test has no test sessions, which is not a condition to report.
pub fn entries(logs_root: &Path) -> Vec<Entry> {
    let mut found = Vec::new();
    for stream in Stream::ALL {
        let dir = stream_dir(logs_root, stream);
        match stream.entry_kind() {
            // Ops are nested one level deeper, under their date.
            EntryKind::File if stream == Stream::Ops => {
                for day in capped::children(&dir) {
                    if day.is_dir() {
                        for file in capped::children(&day) {
                            if file.is_file() {
                                push_entry(&mut found, file, stream);
                            }
                        }
                    } else if day.is_file() {
                        push_entry(&mut found, day, stream);
                    }
                }
            }
            EntryKind::File => {
                for file in capped::children(&dir) {
                    if file.is_file() {
                        push_entry(&mut found, file, stream);
                    }
                }
            }
            EntryKind::Directory => {
                for session in capped::children(&dir) {
                    push_entry(&mut found, session, stream);
                }
            }
        }
    }
    found
}

fn push_entry(into: &mut Vec<Entry>, path: PathBuf, stream: Stream) {
    into.push(capped::entry(path, stream.dir_name()));
}

pub fn usage(logs_root: &Path) -> Usage {
    let found = entries(logs_root);
    let covered: Vec<PathBuf> = Stream::ALL
        .iter()
        .map(|stream| stream_dir(logs_root, *stream))
        .collect();
    let mut usage = capped::usage_of(logs_root, &found, &covered);
    // Every stream is reported, including the ones holding nothing: a page
    // that lists only non-empty streams reads as if the others do not exist.
    for stream in Stream::ALL {
        usage.by_group.entry(stream.dir_name().to_string()).or_default();
    }
    usage
}

pub fn sweep(logs_root: &Path, max_bytes: u64, protect: &[PathBuf], dry_run: bool) -> SweepReport {
    // The logs answer to their cap, never to the free-space floor. They are
    // megabytes against a floor of gigabytes: a volume below it asked them to
    // free 2 GB, they held 17 MB, and the sweep took all 892 entries — a test
    // run's own log directory mid-run among them — and freed nothing that
    // mattered. The build root is what frees a volume; it is reproducible and
    // it is where the bytes are.
    let found = entries(logs_root);
    // What the root holds that no stream covers. It counts against the limit —
    // the limit is on this directory — and no sweep can ever take it, so it is
    // passed in to be counted and reported rather than silently ignored. This
    // root had 174 MB of it, in directories written by the layout that predated
    // the streams, and every sweep reported itself settled.
    let covered: Vec<PathBuf> = Stream::ALL
        .iter()
        .map(|stream| stream_dir(logs_root, *stream))
        .collect();
    let unreachable = capped::usage_of(logs_root, &found, &covered).unmanaged_bytes;
    capped::sweep_entries(
        found,
        max_bytes,
        MIN_MAX_BYTES,
        0,
        unreachable,
        protect,
        dry_run,
    )
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;