yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The detached driver's captured stderr (DESIGN §8.1, §13.3, §4.2, §5.2).
//!
//! A detached `lernie prompt` has no waitable status, so its ops line carries the
//! [`DETACHED_EXIT`] sentinel and an empty `stderr` (§8.1). That made a driver
//! which **dies right after launch** — a version-skew refusal, a missing model
//! config — indistinguishable from a clean launch: exit `-2`, nothing rendered,
//! a prompt that visibly "does nothing". So the child's stderr is routed to a
//! per-spawn **sink file** under the yog state root, and the row's `stderr` is
//! folded in from that file **at read time** ([`fold`]) rather than copied into
//! `ops.jsonl`. The file is the authority; the row is a projection, so the fact
//! is never stored twice and a still-running driver's later output surfaces on
//! the next sweep without rewriting a durable line.
//!
//! A non-empty capture makes the row a rendered failure
//! ([`OpRow::failed`](super::rows::OpRow::failed) already reads `DETACHED_EXIT`
//! that way), which is what stirs the §6/§11 activity surface and the §7.3
//! ichor-red banner — no new signal, the existing machinery fed a fact it was
//! previously denied.
//!
//! **The join key is computed, not stored.** The sink's name derives from facts
//! the ops line already carries — its `ts` and its workspace argument — so the
//! row needs no extra field to find its file, and [`sink`] is the single home of
//! the naming: the spawn side creates that path, [`fold`] reads it back.

use super::{DETACHED_EXIT, OpEntry};
use std::fs;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};

/// The sink directory under the yog state root, beside `ops.jsonl` (§5.2).
const DIR: &str = "detached";

/// The sink leaf's extension.
const EXT: &str = "err";

/// How many trailing bytes of a sink the projection folds in. A driver may
/// chatter for hours; the tail is where a death lands, and the bound keeps the
/// sweep's read cost flat regardless of how long the loop has run.
const TAIL: u64 = 4096;

/// Sink-name stand-in for a workspace path with no file name (`/`, `..`).
const UNNAMED: &str = "workspace";

/// The per-spawn stderr sink for a detached `lernie prompt`:
/// `<state_root>/detached/<ts>-<workspace leaf>.err`. Both sides of the fold go
/// through here — the spawn hands this path to
/// [`spawn_detached`](crate::cli_outbound::Cli::spawn_detached), [`fold`] reads
/// it back — so the naming has one home. `ts` (unix seconds) plus the workspace
/// leaf separates every spawn the operator can actually make: one fire per
/// workspace per second.
pub fn sink(state_root: &Path, ts: &str, workspace: &Path) -> PathBuf {
    let leaf = workspace
        .file_name()
        .map_or_else(|| UNNAMED.to_owned(), |n| n.to_string_lossy().into_owned());
    state_root.join(DIR).join(format!("{ts}-{leaf}.{EXT}"))
}

/// `entry` with its detached child's captured stderr folded in — the read-time
/// projection the ops sweep applies to every tailed line (§4.2, §7.2).
///
/// Only a [`DETACHED_EXIT`] line whose own `stderr` is empty is folded: a
/// **spawn** failure already rode the line at append time (§8.1) and is the
/// authority for that row, so it is never clobbered by a sink. Everything else
/// — piped verbs, synthetic failures — rides back untouched.
pub fn fold(state_root: &Path, entry: &OpEntry) -> OpEntry {
    if entry.exit != DETACHED_EXIT || !entry.stderr.is_empty() {
        return entry.clone();
    }
    // `lernie prompt <workspace> <goal>` behind the resolved binary: argv[2] is
    // the workspace the sink is named for (`clip_goal` only ever trims the last
    // element, so it survives the log intact).
    let Some(workspace) = entry.argv.get(2) else {
        return entry.clone();
    };
    OpEntry {
        stderr: captured(&sink(state_root, &entry.ts, Path::new(workspace))),
        ..entry.clone()
    }
}

/// The tail of a sink file as text: at most [`TAIL`] bytes, starting at a line
/// boundary when the head was clipped (a half-line is noise, not a cause). An
/// absent or unreadable sink — the overwhelmingly common case, a clean launch
/// that never wrote — yields the empty string, leaving the row a clean launch.
fn captured(path: &Path) -> String {
    let Ok((bytes, clipped)) = read_tail(path) else {
        return String::new();
    };
    let text = String::from_utf8_lossy(&bytes);
    match clipped.then(|| text.split_once('\n')).flatten() {
        Some((_partial, rest)) => rest.to_owned(),
        None => text.into_owned(),
    }
}

/// The last [`TAIL`] bytes of `path` plus whether the head was clipped. Seeks
/// rather than reading the whole file: a long-lived driver's sink is unbounded,
/// and this runs per detached row on every sweep.
fn read_tail(path: &Path) -> io::Result<(Vec<u8>, bool)> {
    let mut file = fs::File::open(path)?;
    let from = file.metadata()?.len().saturating_sub(TAIL);
    file.seek(SeekFrom::Start(from))?;
    let mut buf = Vec::new();
    file.read_to_end(&mut buf)?;
    Ok((buf, from > 0))
}

#[cfg(test)]
mod tests;