yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The effectful half of the start flow (DESIGN §3.3, §8.1, §15 M6 Z3): the
//! mint, the piped `bl`/`lernie` executors, the claim worktree cross-check, the
//! detached `lernie prompt`, and the [`prepare`] orchestrator that **runs
//! [`plan`](super::plan)'s output** — no duplicated sequence.
//!
//! Every non-spawn abort leaves a `["yog-step",<name>]` ops row before it returns
//! (§4.2, Z5's [`log_step_failure`]): the mint pool exhaustion ([`on_mint`]), the
//! workspace `mkdir` ([`execute_ensure_workspace`]), and the claim cross-check
//! [`Drift`](StartError::Drift) — so no error class is invisible to the §7.3
//! failed-action surface (the eprintln purge left none behind).

use super::goal::mint_name_of;
use super::{StartInputs, identity_preamble};
use crate::actions::verbs::{self, Outcome, log_step_failure};
use crate::binding::work_worktree_path;
use crate::cli_outbound::Cli;
use crate::names::{MintError, Rng};
use crate::opslog::{self, OpEntry};
use crate::world::seed;
use crate::world::toolgate::{Refusal, ToolchainState};
use std::io;
use std::path::{Path, PathBuf};

const CREATE: &str = "create";
const CLAIM: &str = "claim";
const AS: &str = "--as";
const BODY: &str = "--body";
const NEW: &str = "new";
const PROMPT: &str = "prompt";
const YOG_NAME: &str = "YOG_NAME";
/// The workspace marker (§3.1); a workspace is a dir directly holding it.
const REPO_MARK: &str = "repo.git";
/// The `["yog-step",<name>]` step names for the non-spawn aborts (§4.2).
const MINT: &str = "mint";
const MKDIR: &str = "mkdir";
const DRIFT: &str = "cross-check";

pub use crate::opslog::DETACHED_EXIT;

/// The injected binaries + the ops-log target + the classified toolchain (§14,
/// §16.6 W5). Owned — [`prepare`] borrows each field as it threads them into the
/// per-step executors, and consults `gate` in its dispatch-layer precondition
/// before any spawn.
pub struct Deps {
    pub bl: Cli,
    pub lernie: Cli,
    pub state_root: PathBuf,
    /// The phase-1 capability gate (§16.6 W5): [`prepare`] refuses the start before
    /// any mint/spawn when a tool the payload drives is not `Ok`.
    pub gate: ToolchainState,
}

/// The worktree a claim resolved to and which formula variant matched (§3.3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaimResolved {
    pub worktree: PathBuf,
    pub suffixed: bool,
}

/// The composer's fire-time parameters (§8.1): the resolved workspace `name` (for
/// `YOG_NAME` + the identity stamp), its `workspace` path, the per-rung `cwd`, and
/// the editable `goal` prefill (identity stamped at fire, not carried here).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prepared {
    pub name: String,
    pub workspace: PathBuf,
    pub cwd: PathBuf,
    pub goal: String,
}

/// A start-flow failure. Every variant is already a durable ops row before it
/// rides back (a ran-non-zero verb's [`Outcome`], a synthetic step-failure line
/// for the mint / mkdir / [`Drift`](StartError::Drift)).
#[derive(Debug, thiserror::Error)]
pub enum StartError {
    #[error("`{verb}` failed (exit {}): {}", .outcome.exit, .outcome.stderr)]
    VerbFailed {
        verb: &'static str,
        outcome: Outcome,
    },
    #[error("claim worktree drift: bl printed {stdout:?}, expected {canonical} or {suffixed}")]
    Drift {
        stdout: String,
        canonical: String,
        suffixed: String,
    },
    #[error(transparent)]
    Mint(#[from] MintError),
    #[error(transparent)]
    Seed(#[from] seed::SeedError),
    /// The capability gate refused the start (§16.6 W5) — already a
    /// `["yog-step","gate"]` ops row before it rides back.
    #[error(transparent)]
    GateRefused(#[from] Refusal),
    #[error(transparent)]
    Io(#[from] io::Error),
}

/// Map a mint result to a name or a **logged abort** (§8.1): an exhausted pool
/// leaves a `["yog-step","mint"]` row (Z5) before it returns, so the empty-world
/// bootstrap's one non-spawn failure is a rendered fact, never a dropped error.
pub fn on_mint(
    result: Result<String, MintError>,
    state_root: &Path,
    ts: &str,
    cwd: &Path,
) -> Result<String, StartError> {
    match result {
        Ok(name) => Ok(name),
        Err(e) => {
            log_step_failure(state_root, ts, cwd, MINT, &e.to_string())?;
            Err(StartError::Mint(e))
        }
    }
}

/// Resolve the target to a workspace name (§3.4): an existing name verbatim, or a
/// mint over the injected RNG and occupied set, logging an exhausted pool.
pub fn resolve_name(
    inputs: &StartInputs,
    rng: &mut dyn Rng,
    state_root: &Path,
    ts: &str,
) -> Result<String, StartError> {
    let minted = mint_name_of(
        &inputs.target,
        &inputs.yog_data_root,
        &inputs.claimants,
        rng,
    );
    on_mint(minted, state_root, ts, &inputs.home)
}

/// `bl create <title> --as <name> [--body B]` in the project (§8.2). Returns the
/// minted id (bl prints it on stdout). An empty body is elided.
pub fn execute_create(
    bl: &Cli,
    state_root: &Path,
    ts: &str,
    project: &Path,
    title: &str,
    body: &str,
    name: &str,
) -> Result<String, StartError> {
    // The ungated `run_logged` core (not `verbs::create`) — `prepare` already gated.
    let mut args = vec![CREATE, title, AS, name];
    if !body.is_empty() {
        args.extend([BODY, body]);
    }
    let out = verbs::run_logged(bl, state_root, ts, project, &args)?;
    verb_ok(out, CREATE).map(|o| o.stdout.trim().to_owned())
}

/// `bl claim <id> --as <name>` in the project (§8.1), piped + opslog'd, then the
/// stdout worktree path cross-checked against the bl-delivery formula.
pub fn execute_claim(
    bl: &Cli,
    state_root: &Path,
    ts: &str,
    project: &Path,
    id: &str,
    name: &str,
    balls_state_root: &Path,
) -> Result<ClaimResolved, StartError> {
    let out = verb_ok(
        verbs::run_logged(bl, state_root, ts, project, &[CLAIM, id, AS, name])?,
        CLAIM,
    )?;
    cross_check_claim(
        &out.stdout,
        balls_state_root,
        project,
        id,
        name,
        state_root,
        ts,
    )
}

/// Cross-check `bl claim`'s stdout against the bl-delivery worktree formula
/// (§3.3, §5.1 #5): the canonical `<id>` leaf or the `<id>-<claimant>` variant
/// matches; anything else is a workspace-convention [`Drift`](StartError::Drift),
/// logged as a `["yog-step","cross-check"]` row (Z5) before it returns.
pub fn cross_check_claim(
    stdout: &str,
    balls_state_root: &Path,
    project: &Path,
    id: &str,
    name: &str,
    state_root: &Path,
    ts: &str,
) -> Result<ClaimResolved, StartError> {
    let got = PathBuf::from(stdout.trim());
    let canonical = work_worktree_path(balls_state_root, project, id, None);
    let suffixed = work_worktree_path(balls_state_root, project, id, Some(name));
    if got == canonical {
        return Ok(ClaimResolved {
            worktree: canonical,
            suffixed: false,
        });
    }
    if got == suffixed {
        return Ok(ClaimResolved {
            worktree: suffixed,
            suffixed: true,
        });
    }
    let err = StartError::Drift {
        stdout: got.display().to_string(),
        canonical: canonical.display().to_string(),
        suffixed: suffixed.display().to_string(),
    };
    log_step_failure(state_root, ts, project, DRIFT, &err.to_string())?;
    Err(err)
}

/// Ensure the bound workspace exists (§8.1, §3.1): skip when `<workspace>/repo.git`
/// is present (resume is the same path as opening). Otherwise `mkdir -p` the
/// parent chain — a failure logs a `["yog-step","mkdir"]` row (Z5) before it
/// returns — and `lernie new <workspace>` piped + opslog'd.
pub fn execute_ensure_workspace(
    lernie: &Cli,
    state_root: &Path,
    ts: &str,
    workspace: &Path,
) -> Result<bool, StartError> {
    if workspace.join(REPO_MARK).exists() {
        return Ok(false);
    }
    let parent = workspace.parent().unwrap_or(workspace);
    if let Err(e) = std::fs::create_dir_all(parent) {
        log_step_failure(state_root, ts, parent, MKDIR, &e.to_string())?;
        return Err(StartError::Io(e));
    }
    let ws_s = workspace.to_string_lossy();
    verb_ok(
        verbs::run_logged(lernie, state_root, ts, parent, &[NEW, &ws_s])?,
        NEW,
    )?;
    Ok(true)
}

/// `lernie prompt <workspace> <goal>` fired **detached** (§8.1): own process
/// group, stdin/stdout→null, stderr→the per-spawn sink
/// ([`opslog::detached::sink`]), `YOG_NAME=<name>` layered (§8), cwd the §3.4
/// driver dir. The goal is `identity_preamble` (§3.3) prepended to the edited
/// prefill; the *logged* argv rides the full goal through [`opslog::clip_goal`],
/// which trims it so the serialized line stays ≤ CAP/PIPE_BUF (§4.2 atomicity) —
/// the *spawned* one is full. Only the spawn is logged ([`DETACHED_EXIT`]); a
/// spawn failure rides `stderr` here, while a child that dies *after* launching
/// speaks through the sink, folded into this row at read time (§13.3 amended).
pub fn execute_prompt(
    lernie: &Cli,
    state_root: &Path,
    ts: &str,
    name: &str,
    cwd: &Path,
    workspace: &Path,
    goal: &str,
) -> io::Result<()> {
    let composed = format!("{}\n\n{goal}", identity_preamble(name));
    let ws_s = workspace.to_string_lossy();
    let named = lernie.and_env(vec![(YOG_NAME.to_owned(), name.to_owned())]);
    let sink = opslog::detached::sink(state_root, ts, workspace);
    let spawn = named.spawn_detached(Some(cwd), &sink, &[PROMPT, ws_s.as_ref(), &composed]);
    // The full goal rides argv's tail; `clip_goal` trims it so the serialized
    // line holds ≤ CAP/PIPE_BUF after JSON escaping (the spawned goal is full).
    let entry = OpEntry {
        ts: ts.to_owned(),
        argv: vec![
            lernie.binary().display().to_string(),
            PROMPT.to_owned(),
            ws_s.into_owned(),
            composed,
        ],
        cwd: cwd.display().to_string(),
        exit: DETACHED_EXIT,
        stdout: String::new(),
        stderr: spawn
            .as_ref()
            .err()
            .map(ToString::to_string)
            .unwrap_or_default(),
    };
    opslog::append(state_root, &opslog::clip_goal(&entry))?;
    spawn.map(|_pid| ()).map_err(io::Error::other)
}

/// Return `out` iff the verb exited 0, else a [`StartError::VerbFailed`] carrying
/// its already-logged [`Outcome`] (§8.2).
fn verb_ok(out: Outcome, verb: &'static str) -> Result<Outcome, StartError> {
    if out.ok() {
        Ok(out)
    } else {
        Err(StartError::VerbFailed { verb, outcome: out })
    }
}