yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! `ops.jsonl`: the durable action-outcome log (DESIGN §4.2, §15 Y15).
//!
//! yog is a pure renderer of disk except for two owned files; this is one of
//! them. Every **attempted** yog-initiated CLI action appends one JSON line to
//! `<yog_state_root>/ops.jsonl` — `{ts, argv, cwd, exit, stdout, stderr}` — so
//! both instances tail one shared history instead of holding gate/close output
//! RAM-only (§4.2 as amended: "closes the one durability leak"). A *completed*
//! run logs its real outcome; a spawn or non-spawn **step failure** logs a
//! synthetic line ([`OpEntry::synthetic_failure`] / [`OpEntry::step_failure`])
//! so no error class is un-logged — the §7.3 failed-action row depends on it.
//!
//! **Atomicity by size cap.** The line *including its newline* is hard-capped
//! at [`CAP`] = 4096 bytes (PIPE_BUF): a write that size or under is a single
//! atomic `O_APPEND` on Linux, so two instances never interleave.
//! [`build_line`] is the pure capper — it truncates `stdout`, then `stderr`,
//! keeping heads and stamping `"truncated":true`; the fixed fields
//! (`ts`/`argv`/`cwd`/`exit`) never truncate, so a pathological argv is the one
//! case a line may exceed the cap (structurally unavoidable; §4.2 is silent
//! there — we stay strict-otherwise).
//!
//! **No clock here.** `ts` is a data field, stamped upstream from the caller's
//! clock; nothing in this module reads wall-clock time, keeping [`build_line`]
//! and the tail parser pure and deterministic.
//!
//! **One field is not stored here.** A detached spawn's `stderr` is captured to
//! a per-spawn sink file and folded into the row at read time
//! ([`detached`]) — the log records the launch, the sink records what the
//! launched process said.

use std::fs;
use std::io::{self, Write};
use std::path::Path;

use serde_json::{Map, Value};

/// The hard cap on a serialized line *including* its trailing newline, in
/// bytes. 4096 = PIPE_BUF: at or under it an `O_APPEND` write is atomic.
pub const CAP: usize = 4096;

/// The log's leaf name under the yog state root (§4.2).
const FILENAME: &str = "ops.jsonl";

/// `exit` sentinel for a piped verb whose status was unobservable
/// (`ExitInfo::Unknown`, [`crate::cli_outbound::ExitInfo::shell_code`]): the
/// process **ran** — not a rendered failure ([`rows::OpRow::failed`]).
pub const PIPED_UNOBSERVED: i32 = -1;

/// `exit` sentinel for the detached `lernie prompt` (§8.1, §13.3): a detached
/// child in its own process group has no waitable status, so `-2` records
/// "launched detached; exit deliberately unobserved". A *clean, silent* launch
/// is a success; a spawn *failure* rides the same line with the error in
/// `stderr`, and a child that spoke on stderr *after* launching has that text
/// folded in from its sink at read time ([`detached`]) — both make the one
/// detached case [`rows::OpRow::failed`] flags.
pub const DETACHED_EXIT: i32 = -2;

/// `exit` sentinel for a **synthetic failure line** (§4.2 as amended): an
/// attempted action that produced no process status — a piped spawn that never
/// launched, or a non-spawn yog-step failure ([`OpEntry::synthetic_failure`] /
/// [`OpEntry::step_failure`]). The failure text always rides in `stderr`.
pub const SYNTHETIC_EXIT: i32 = -3;

/// `argv[0]` of a non-spawn step-failure line (§4.2): the logical step name
/// rides as `argv[1]`, e.g. `["yog-step","mint"]` — an error class with no real
/// binary still gets a rendered ops row (the §7.3 failed-action row depends on
/// every error class having one).
pub const YOG_STEP: &str = "yog-step";

/// One attempted CLI action — the on-disk `ops.jsonl` record (§4.2 as amended):
/// a completed run's captured outcome, or a synthetic failure line for a spawn
/// or non-spawn step that never produced a process status.
///
/// `ts` is an already-formatted timestamp string (e.g. RFC3339) supplied by
/// the caller's clock; this module never reads time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpEntry {
    pub ts: String,
    pub argv: Vec<String>,
    pub cwd: String,
    pub exit: i32,
    pub stdout: String,
    pub stderr: String,
}

impl OpEntry {
    /// A **synthetic failure line** (§4.2 as amended): an attempted action that
    /// produced no process status. `argv` is the intended argv, `stderr` the
    /// failure text, `stdout` empty, `exit` [`SYNTHETIC_EXIT`]. This is the one
    /// place "attempted" diverges from "completed" — a spawn that never launched
    /// still leaves a rendered fact (the §7.3 row), never a dropped error.
    pub fn synthetic_failure(ts: String, argv: Vec<String>, cwd: String, stderr: String) -> Self {
        Self {
            ts,
            argv,
            cwd,
            exit: SYNTHETIC_EXIT,
            stdout: String::new(),
            stderr,
        }
    }

    /// A non-spawn **step-failure line** (§4.2): the mint/mkdir/cross-check class
    /// that names no binary. Encodes `argv = ["yog-step", <step>]` over
    /// [`synthetic_failure`](Self::synthetic_failure); the start flow (Z3) logs
    /// its non-spawn aborts through this same encoding.
    pub fn step_failure(ts: String, step: &str, cwd: String, stderr: String) -> Self {
        Self::synthetic_failure(
            ts,
            vec![YOG_STEP.to_string(), step.to_string()],
            cwd,
            stderr,
        )
    }
}

/// The pure ≤[`CAP`] line serializer and the caller-side argv clip (§4.2). Split
/// out of this file per §12's line-budget discipline.
pub mod line;
pub use line::{build_line, clip_goal};

/// Append `entry`'s capped line to `<state_root>/ops.jsonl` via `O_APPEND`,
/// creating the state dir if absent. Atomic against a concurrent instance by
/// the [`CAP`] size bound.
pub fn append(state_root: &Path, entry: &OpEntry) -> io::Result<()> {
    fs::create_dir_all(state_root)?;
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(state_root.join(FILENAME))?;
    file.write_all(&build_line(entry))?;
    Ok(())
}

/// The last `max` parseable entries, oldest-first (newest-last). A missing file
/// or unreadable bytes yield an empty view; each line parses forgivingly — a
/// corrupt or mid-write-torn line is skipped, never an error.
pub fn tail(state_root: &Path, max: usize) -> Vec<OpEntry> {
    let Ok(bytes) = fs::read(state_root.join(FILENAME)) else {
        return Vec::new();
    };
    let mut entries: Vec<OpEntry> = bytes
        .split(|&b| b == b'\n')
        .filter_map(|line| std::str::from_utf8(line).ok())
        .filter_map(parse_line)
        .collect();
    let overflow = entries.len().saturating_sub(max);
    entries.drain(..overflow);
    entries
}

/// Parse one line into an [`OpEntry`], or `None` when it is not a JSON object.
/// Individual fields default when absent or mistyped (forgiving, per §4.2).
fn parse_line(line: &str) -> Option<OpEntry> {
    let value: Value = serde_json::from_str(line).ok()?;
    let obj = value.as_object()?;
    Some(OpEntry {
        ts: str_field(obj, "ts"),
        argv: obj
            .get("argv")
            .and_then(Value::as_array)
            .map(|a| {
                a.iter()
                    .filter_map(Value::as_str)
                    .map(String::from)
                    .collect()
            })
            .unwrap_or_default(),
        cwd: str_field(obj, "cwd"),
        exit: obj.get("exit").and_then(Value::as_i64).unwrap_or(0) as i32,
        stdout: str_field(obj, "stdout"),
        stderr: str_field(obj, "stderr"),
    })
}

/// A string field of `obj`, or `""` when absent or non-string.
fn str_field(obj: &Map<String, Value>, key: &str) -> String {
    obj.get(key)
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string()
}

/// View-models over the log (§4.2, §7.3, §11), split out per §12's line budget:
/// `rows` = the expandable [`OpRow`] and the [`SurfaceFailure`] a surface holds;
/// `live` = §6's retirement projection over a tail of rows (which failures are
/// still live — the log keeps every failure, prominence is derived) + [`Activity`].
pub mod live;
pub mod rows;
pub use live::{Activity, activity, live_failures};
pub use rows::{OpRow, SurfaceFailure};

/// The detached driver's captured stderr (§8.1, §13.3): the per-spawn sink file
/// and the read-time fold that projects its tail into the row.
pub mod detached;

#[cfg(test)]
mod tests;