yog 0.0.12

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The Steps tab's JSON shape (§8.5, bl-6233) — the headless serialization of
//! both tiers: the cheap per-step summary list and one step's drill-in. Beside
//! its type for the reason `workdiff::wire` gives — framings, wounds and
//! jsonview docs are this module's own vocabulary — and cut along the same
//! two-tier seam the module itself is cut along.

use serde_json::{Map, Value, json};

use super::{Doc, Orphan, StepDetail, StepSummary, StepsView, Tail, ToolIo, Wound};
use crate::budgets::BudgetSpend;
use crate::git_tree::Framing;

/// The decoders, beside the encoders they undo (bl-7067, REMOTE §9 step 2).
pub(crate) mod decode;

/// The `steps` reply body: one row per step, in sequence order, and the
/// view-level orphaned-tail state (bl-ace6) — the wound's class-token shape,
/// at the top because it is not any one step's fact.
pub(crate) fn steps(view: &StepsView) -> Value {
    let mut map = Map::new();
    map.insert("ok".to_owned(), json!(true));
    map.insert("kind".to_owned(), json!("steps"));
    map.insert(
        "rows".to_owned(),
        Value::Array(view.steps.iter().map(step_row).collect()),
    );
    map.insert("orphan".to_owned(), json!(orphan_token(&view.orphan)));
    if let Orphan::Spoke(_, reason) = &view.orphan {
        map.insert("orphan_reason".to_owned(), json!(reason));
    }
    Value::Object(map)
}

/// Which [`Tail`] is orphaned, or none — a class token rather than the
/// `orphaned` boolean the pair carried until bl-abba, for exactly the reason
/// [`wound_token`] is one: with a second shape the (bool, Option<reason>)
/// pair stopped being a bijection, and the honest fix is a discriminant, not
/// a second boolean beside the first. The reason key still separates mute
/// from spoken, which is the one thing it ever did.
fn orphan_token(orphan: &Orphan) -> &'static str {
    match orphan {
        Orphan::None => "none",
        Orphan::Mute(tail) | Orphan::Spoke(tail, _) => match tail {
            Tail::Mail => "mail",
            Tail::ToolWindow => "tool_window",
        },
    }
}

/// One step's summary. The timestamps and the read-state commit are absent
/// keys when `meta.json` did not carry them — the same absence the list paints,
/// never a zero or an empty string standing in for a fact nobody recorded.
fn step_row(step: &StepSummary) -> Value {
    let mut map = Map::new();
    map.insert("seq".to_owned(), json!(step.seq));
    map.insert("framing".to_owned(), json!(framing_token(step.framing)));
    map.insert("attempts".to_owned(), json!(step.attempts));
    map.insert("tokens".to_owned(), spend_value(&step.tokens));
    for (key, value) in [
        ("commit", step.commit.as_ref()),
        ("started_at", step.started_at.as_ref()),
        ("ended_at", step.ended_at.as_ref()),
    ] {
        if let Some(value) = value {
            map.insert(key.to_owned(), json!(value));
        }
    }
    // The §8.3 login affordance: offered at all, and the provider row it points
    // at when one was derivable. `auth_row` absent is `Unrouted` — the
    // affordance paints and there is nothing to pick for you.
    map.insert("auth_failed".to_owned(), json!(step.auth_failed.offered()));
    if let Some(row) = step.auth_failed.row() {
        map.insert("auth_row".to_owned(), json!(row));
    }
    // The §7.3 wound: which class it is, and the adapter's own reason when the
    // no-response class left words behind.
    map.insert("wound".to_owned(), json!(wound_token(&step.wound)));
    if let Wound::Spoke(reason) = &step.wound {
        map.insert("wound_reason".to_owned(), json!(reason));
    }
    Value::Object(map)
}

/// The §7.3 wound's **class**, beside the optional reason (bl-fb87). A class
/// token rather than the `wounded` boolean the pair used to carry: with a
/// fourth arm the (bool, Option<reason>) pair stopped being a bijection, and
/// the honest fix is a discriminant, not a second boolean beside the first.
/// The two no-response arms share one token because the reason key is exactly
/// what tells them apart.
fn wound_token(wound: &Wound) -> &'static str {
    match wound {
        Wound::None => "none",
        Wound::Mute | Wound::Spoke(_) => "no_response",
        Wound::OutputLimit => "output_limit",
    }
}

/// The §4.4 terminal classification, in the three words the seat renders.
fn framing_token(framing: Framing) -> &'static str {
    match framing {
        Framing::Complete => "complete",
        Framing::Failed => "failed",
        Framing::Killed => "killed",
    }
}

/// The four ARCH §6 counters and their total — the total is derived, and it is
/// carried because every seat that reads a step reads it against a ceiling.
///
/// `pub(crate)` since bl-7067: the §3.5 board figure spells its token half in
/// exactly this shape, the way `files_view::wire::preview_value` already serves
/// the work diff's patch. One spelling of one thing, in one place.
pub(crate) fn spend_value(spend: &BudgetSpend) -> Value {
    json!({
        "input": spend.input_tokens, "output": spend.output_tokens,
        "cache_read": spend.cache_read_tokens, "cache_write": spend.cache_write_tokens,
        "total": spend.total_tokens(),
    })
}

/// The `step` reply body: one step's four record files, its `response.json`
/// events, every tool call's input and output, and each capture log that has
/// bytes (bl-83d6) — an absent key for a log with nothing in it, the same way
/// the `files` reply omits a preview nobody asked for. A key carrying an empty
/// text would be a second encoding of "there is nothing there".
pub(crate) fn detail(detail: &StepDetail) -> Value {
    let mut map = Map::new();
    for (key, value) in [
        ("ok", json!(true)),
        ("kind", json!("step")),
        ("seq", json!(detail.seq)),
        ("meta", doc_value(&detail.meta)),
        ("request", doc_value(&detail.request)),
        ("staging", doc_value(&detail.staging)),
        (
            "response",
            Value::Array(detail.response.iter().map(doc_value).collect()),
        ),
        (
            "tools",
            Value::Array(detail.tools.iter().map(tool_value).collect()),
        ),
    ] {
        map.insert(key.to_owned(), value);
    }
    for (key, log) in [("stderr", &detail.stderr), ("driver", &detail.driver)] {
        if let Some(preview) = log {
            map.insert(
                key.to_owned(),
                crate::files_view::wire::preview_value(preview),
            );
        }
    }
    Value::Object(map)
}

/// One record file as data: parsed, absent, or bytes that are not JSON. The
/// three stay distinct on the wire exactly as they do on screen — rendered
/// bare, malformed content is indistinguishable from a file whose content
/// happens to be that text, which is why [`super::UNPARSED`] exists at all.
fn doc_value(doc: &Doc) -> Value {
    match doc {
        // `raw` rides beside the tree because a `serde_json::Value` is not a
        // lossless record of its source (key order, spacing and number spelling
        // all go), so the tree alone could never answer "what does the file
        // say" (S7-T1).
        Doc::Json { value, raw } => json!({
            "kind": "json", "value": value,
            "raw": String::from_utf8_lossy(raw),
        }),
        Doc::Absent => json!({ "kind": "absent" }),
        Doc::Unparsed(raw) => json!({
            "kind": "unparsed", "note": super::UNPARSED,
            "raw": String::from_utf8_lossy(raw),
        }),
    }
}

/// One tool call's records (ARCH §3.3), with litany's own `is_error` reading.
fn tool_value(tool: &ToolIo) -> Value {
    json!({
        "tool_id": tool.tool_id, "is_error": tool.is_error,
        "input": doc_value(&tool.input), "output": doc_value(&tool.output),
    })
}