yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Per-agent steps inspector view-model (DESIGN §11 Altitude-2 Steps tab;
//! §5.1 #13; §15 Y13). Milestone M2's last piece: browse every byte.
//!
//! A step is `steps/<agent-id>/NNN/` (ARCH §2.3): `meta.json`
//! (`{commit, started_at, ended_at}`), `request.json` (the wire request
//! snapshot), `response.json` (JSONL of §4.4 events), `staging.json` (the
//! transcript entry under construction), and `tools/<tool-id>/{input,output}
//! .json`. Yog is a pure reader (§3.5): everything here is a function of
//! those bytes, re-read per tick, deriving nothing it can read.
//!
//! Two tiers, so the cheap list never pays for the heavy drill-in:
//! [`build`] summarizes every step (framing, attempts, tokens, timestamps)
//! for the list; [`detail`] parses one selected step's files into jsonview
//! trees on demand.
//!
//! Nothing here re-parses a record another module already owns
//! (single source of truth): per-step **framing** and **attempt count** come
//! from the git_tree §4.4 terminal classifier, and **token counts** from the
//! budgets Usage fold — both reused, never duplicated (§15 Y13).

use std::path::Path;

use serde_json::Value;

use crate::budgets::{BudgetSpend, spend_from_bytes};
use crate::git_tree::{AgentState, Framing, framing, segment_count};

mod render;
mod wound;
pub use render::{StepTab, render};
pub use wound::{NO_RESPONSE, latest_step_no_response};

/// Conv-repo subdir of per-agent step records (ARCH §2.3).
const STEPS_DIR: &str = "steps";
const META_FILE: &str = "meta.json";
const REQUEST_FILE: &str = "request.json";
const RESPONSE_FILE: &str = "response.json";
const STAGING_FILE: &str = "staging.json";
const TOOLS_SUBDIR: &str = "tools";
const INPUT_FILE: &str = "input.json";
const OUTPUT_FILE: &str = "output.json";
/// Zero-padded step-sequence width (`001`, `002`, …) per ARCH §2.3.
const STEP_SEQ_WIDTH: usize = 3;

/// One step's list-row summary. `framing` and `tokens` are reused from the
/// git_tree terminal classifier and the budgets Usage fold; the rest reads
/// `meta.json` (§2.3 `{commit, started_at, ended_at}`), each field absent
/// when `meta.json` is missing or malformed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepSummary {
    /// Zero-padded sequence directory name (`001`).
    pub seq: String,
    /// §4.4 outcome of `response.json` — complete / failed / killed.
    pub framing: Framing,
    /// Completed attempt segments (`end` events, §4.4).
    pub attempts: usize,
    /// Whole-segment token spend for this step (§6).
    pub tokens: BudgetSpend,
    /// Branch-tip sha at step-start (`meta.commit`, §2.10).
    pub commit: Option<String>,
    pub started_at: Option<String>,
    pub ended_at: Option<String>,
    /// The **Login affordance** flag (§8.3 detection, §15 M6 Z8): this step is an
    /// auth-shaped failure — framing Failed with credential/auth-class error text
    /// ([`crate::login::auth::is_auth_failure`]). When set, the shell paints Login
    /// one click away beside the step (a prompt-time failure surfaces here as
    /// derived agent state, §13.3). Logic covered; shell paints.
    pub auth_failed: bool,
    /// The §7.3 **no-response wound** (the `wound` module): this step's driver
    /// produced nothing — no response bytes and no settled `meta.json` — and
    /// nobody is driving the agent. Renders as a failure row
    /// ([`NO_RESPONSE`], ichor) instead of the quiet ash "stopped" its framing
    /// alone reads as.
    pub no_response: bool,
}

/// The ordered per-step summaries for one agent's `steps/<agent-id>/` tree.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StepsView {
    pub steps: Vec<StepSummary>,
}

/// Summarize every step of `agent_id` in `workspace`, in sequence order. A
/// missing `steps/<agent-id>/` tree yields an empty view.
///
/// `state` is the agent's already-derived §3.5 liveness — the second half of
/// the `wound` rule. A driver at work is still filling its newest step, so
/// that one step's unanswered shape is a call in flight, not a wound (§10:
/// never a false definite). Every other field is a pure read of the step's own
/// bytes and ignores `state`.
pub fn build(workspace: &Path, agent_id: &str, state: AgentState) -> StepsView {
    let mut steps: Vec<StepSummary> = step_seqs(workspace, agent_id)
        .into_iter()
        .map(|seq| summarize(workspace, agent_id, &seq))
        .collect();
    if wound::driven(state)
        && let Some(newest) = steps.last_mut()
    {
        newest.no_response = false;
    }
    StepsView { steps }
}

/// The zero-padded `NNN` step dirs under `steps/<agent-id>/`, numeric order.
/// Non-step entries (stray files, odd names) are skipped; an absent tree is
/// empty. Lexicographic sort over fixed-width digits is numeric order.
fn step_seqs(workspace: &Path, agent_id: &str) -> Vec<String> {
    let dir = workspace.join(STEPS_DIR).join(agent_id);
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut seqs: Vec<String> = entries
        .flatten()
        .filter_map(|entry| {
            let name = entry.file_name().to_str()?.to_string();
            let is_seq = name.len() == STEP_SEQ_WIDTH
                && name.bytes().all(|b| b.is_ascii_digit())
                && entry.path().is_dir();
            is_seq.then_some(name)
        })
        .collect();
    seqs.sort();
    seqs
}

fn summarize(workspace: &Path, agent_id: &str, seq: &str) -> StepSummary {
    let step = workspace.join(STEPS_DIR).join(agent_id).join(seq);
    let response = std::fs::read(step.join(RESPONSE_FILE)).unwrap_or_default();
    let meta_bytes = std::fs::read(step.join(META_FILE)).ok();
    let meta = meta_bytes
        .as_ref()
        .and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok());
    StepSummary {
        seq: seq.to_string(),
        framing: framing(&response),
        attempts: segment_count(&response),
        tokens: spend_from_bytes(&response),
        commit: meta_field(meta.as_ref(), "commit"),
        started_at: meta_field(meta.as_ref(), "started_at"),
        ended_at: meta_field(meta.as_ref(), "ended_at"),
        auth_failed: crate::login::auth::is_auth_failure(&response),
        no_response: wound::unanswered(&response, meta_bytes.is_some()),
    }
}

/// A string field of the parsed `meta.json`, or `None` when meta is
/// absent/malformed or the field is missing / non-string.
fn meta_field(meta: Option<&Value>, key: &str) -> Option<String> {
    meta?.get(key)?.as_str().map(str::to_string)
}

/// A drill-in document: parsed JSON (rendered as a jsonview tree) or verbatim
/// bytes when the file is absent or doesn't parse — every byte stays
/// inspectable (§11).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Doc {
    Json(Value),
    Raw(Vec<u8>),
}

impl Doc {
    /// Parse bytes into a tree, or keep them verbatim on a parse failure
    /// (an empty file is empty raw).
    fn of_bytes(bytes: Vec<u8>) -> Doc {
        match serde_json::from_slice(&bytes) {
            Ok(value) => Doc::Json(value),
            Err(_) => Doc::Raw(bytes),
        }
    }

    /// Read a file into a [`Doc`]; a missing/unreadable file is empty raw.
    fn of_file(path: &Path) -> Doc {
        Doc::of_bytes(std::fs::read(path).unwrap_or_default())
    }
}

/// One tool call's on-disk records (ARCH §3.3). `is_error` mirrors lernie's
/// own `is_error = exit_code != 0` (`spawn.rs`) — derived from
/// `output.json`'s `exit_code`, `false` when output is absent or carries no
/// exit code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolIo {
    pub tool_id: String,
    pub input: Doc,
    pub output: Doc,
    pub is_error: bool,
}

/// One step's drill-in: the four record files as jsonview docs, `response
/// .json` split per JSONL event, and every tool call's input/output. Built
/// on demand for the selected step only.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepDetail {
    pub seq: String,
    pub meta: Doc,
    pub request: Doc,
    pub staging: Doc,
    /// `response.json` per line — each event a jsonview tree, a malformed
    /// line kept raw.
    pub response: Vec<Doc>,
    pub tools: Vec<ToolIo>,
}

/// Build the drill-in for one step of `agent_id`. Every file is read
/// forgivingly: absent or malformed content surfaces as raw bytes, never an
/// error.
pub fn detail(workspace: &Path, agent_id: &str, seq: &str) -> StepDetail {
    let step = workspace.join(STEPS_DIR).join(agent_id).join(seq);
    StepDetail {
        seq: seq.to_string(),
        meta: Doc::of_file(&step.join(META_FILE)),
        request: Doc::of_file(&step.join(REQUEST_FILE)),
        staging: Doc::of_file(&step.join(STAGING_FILE)),
        response: response_events(&step.join(RESPONSE_FILE)),
        tools: tool_ios(&step.join(TOOLS_SUBDIR)),
    }
}

/// Split `response.json` into per-line docs (empty lines dropped). A missing
/// file yields no events.
fn response_events(path: &Path) -> Vec<Doc> {
    let Ok(bytes) = std::fs::read(path) else {
        return Vec::new();
    };
    bytes
        .split(|&b| b == b'\n')
        .filter(|line| !line.is_empty())
        .map(|line| Doc::of_bytes(line.to_vec()))
        .collect()
}

/// One [`ToolIo`] per `<tool-id>/` subdir, sorted by tool-id (the wire id is
/// monotone in call order). A missing `tools/` dir yields none.
fn tool_ios(tools_dir: &Path) -> Vec<ToolIo> {
    let Ok(entries) = std::fs::read_dir(tools_dir) else {
        return Vec::new();
    };
    let mut tools: Vec<ToolIo> = entries
        .flatten()
        .filter_map(|entry| {
            let path = entry.path();
            if !path.is_dir() {
                return None;
            }
            let tool_id = entry.file_name().to_str()?.to_string();
            let output = Doc::of_file(&path.join(OUTPUT_FILE));
            let is_error = output_is_error(&output);
            Some(ToolIo {
                tool_id,
                input: Doc::of_file(&path.join(INPUT_FILE)),
                output,
                is_error,
            })
        })
        .collect();
    tools.sort_by(|a, b| a.tool_id.cmp(&b.tool_id));
    tools
}

/// Did the tool exit non-zero? Reads `output.json`'s `exit_code` (§3.3
/// `{stdout, stderr, exit_code, …}`); a raw/absent output or missing code is
/// not an error.
fn output_is_error(output: &Doc) -> bool {
    match output {
        Doc::Json(value) => value
            .get("exit_code")
            .and_then(Value::as_i64)
            .is_some_and(|code| code != 0),
        Doc::Raw(_) => false,
    }
}

#[cfg(test)]
mod tests;