yog 0.0.52

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The gesture codec (§8.5): one JSON envelope per [`Gesture`], `op` the
//! discriminant, every parameter a named field. This is the headless
//! serialization of the boundary — the deposit file's whole content, the
//! `yog gesture` argument, and nothing the GUI ever writes (its serialization
//! is the in-RAM variant itself).
//!
//! Encode and decode are both exhaustive over [`Gesture`], which is the §4.8
//! compile gate: a new variant does not build until it has a spelling here.
//! Decode is **strict** — an unknown `op`, a missing field, a mistyped value
//! each refuse with a reason. A gesture is an instruction, not an observation;
//! the forgiving-parse discipline of `ops.jsonl` reads does not apply.

use serde_json::{Value, json};

use super::{Action, Gesture};

/// The `bl` family's five envelopes and, since bl-92d3, its whole spelling in
/// both directions — its own family file, the seam every sibling here is
/// already cut on.
mod balls;
mod config;
mod control;
/// The two **depositing** envelopes (bl-a33d), one family file on the seam
/// every other family here is cut on: a plain send and send-and-interrupt carry
/// the same three fields, and the only difference is what the engine does once
/// the deposit lands.
mod deposit;
mod fan;
pub(crate) mod fields;
mod fleet;
pub(crate) use fleet::{ARM as FLEET_ARM, DISARM as FLEET_DISARM};
mod fork;
mod monitor;
mod query;
mod start;
mod tools;
use config::{encode_file, encode_tuning};
use deposit::{INTERRUPT, MESSAGE, deposit, deposited};
use fields::{act, obj, opt_path_of, opt_str_of, path_of, str_of, strings_of, usize_of};
use start::{decode_payload, decode_prepared, encode_start, opt_field};
pub(crate) use start::{
    decode_prepared as prepared_from_value, encode_prepared as prepared_value, join_token,
    origin_token, parse_join, parse_origin,
};

/// Encode a gesture to its deposit envelope. Total over the surface.
pub fn encode(gesture: &Gesture) -> Value {
    match gesture {
        Gesture::Act(action) => encode_action(action),
        Gesture::Ask(query) => query::encode(query),
    }
}

fn encode_action(action: &Action) -> Value {
    match action {
        Action::Message {
            workspace,
            agent,
            content,
        } => deposit(MESSAGE, workspace, agent, content),
        Action::Interrupt {
            workspace,
            agent,
            content,
        } => deposit(INTERRUPT, workspace, agent, content),
        Action::Stop {
            workspace,
            agent,
            children,
        } => json!({ "op": "stop", "workspace": workspace,
                     "agent": agent, "children": children }),
        Action::Scan { workspace } => json!({ "op": "scan", "workspace": workspace }),
        Action::Nudge { workspace, agent } => at_agent("nudge", workspace, agent),
        Action::Retarget { workspace, agent } => at_agent("retarget", workspace, agent),
        // The §8.2 `bl` family's five, each spelled in its family file
        // (bl-c2bd), one row since bl-92d3 exactly as the fan's three are.
        Action::Ball(verb) => balls::encode(verb),
        // The §8.1 start family's two, beside the `Prepared` body they share.
        Action::Prepare { .. } | Action::Prompt { .. } => encode_start(action),
        // The §4.10 fan's three, each spelled in its family file (bl-c2bd).
        Action::Fan(verb) => fan::encode_verb(verb),
        Action::DeleteWorkspace { workspace, typed } => {
            json!({ "op": "delete-workspace", "workspace": workspace,
                    "typed": typed })
        }
        Action::DeleteAgent {
            workspace,
            agent,
            typed,
        } => json!({ "op": "delete-agent", "workspace": workspace,
                     "agent": agent, "typed": typed }),
        Action::Monitor(verb) => monitor::encode(verb),
        Action::Fleet(verb) => fleet::encode(verb),
        Action::AnswerHold {
            workspace,
            agent,
            ruling,
        } => control::encode(workspace, agent, *ruling),
        Action::Floor {
            workspace,
            agent,
            raised,
        } => control::encode_floor(workspace, agent, *raised),
        Action::Ack => json!({ "op": "ack" }),
        Action::MarkSeen { workspace, agent } => at_agent("seen", workspace, agent),
        // The §4.1 pin's two directions (bl-b986), the floor's shape: two ops
        // for one variant, so unpinning is an instruction and not a missing
        // field.
        Action::Pin { workspace, pinned } => {
            json!({ "op": if *pinned { PIN } else { UNPIN }, "workspace": workspace })
        }
        Action::ClearTrail => json!({ "op": "clear-trail" }),
        Action::ApplyConfig { file, text } => {
            json!({ "op": "config", "target": encode_file(file), "text": text })
        }
        Action::SetMarks { workspace, branch } => {
            json!({ "op": "marks", "workspace": workspace, "branch": branch })
        }
        Action::PickModel {
            workspace,
            role,
            provider,
            model,
        } => json!({ "op": "model", "workspace": workspace,
                     "role": role, "provider": provider, "model": model }),
        // The §9.4 tuning pair (bl-23bd), spelled in the config family's file:
        // one carrier here, two ops on the wire, which is what makes them free
        // of a version bump (REMOTE §3).
        Action::Tune(tuning) => encode_tuning(tuning),
        Action::Fork {
            workspace,
            parent,
            attempt,
            goal,
        } => fork::encode(workspace, parent, attempt, goal),
        Action::Advertise { tools } => tools::encode(tools),
        // REMOTE §1.4's enrollment (bl-f4e3): the workspace it seats the new
        // client in, the common name its certificate will carry, and the grade
        // — in the one grade vocabulary `Grade::word`/`of` spell both ways.
        // `address` rides only when the operator stated one (bl-fec6): absent
        // is "the address this engine wrote for itself", which the executor
        // reads, and a null would be a second spelling of the same absence.
        Action::Enroll(request) => {
            let mut map = serde_json::Map::new();
            map.insert("op".to_owned(), json!(ENROLL));
            map.insert("workspace".to_owned(), json!(request.workspace));
            map.insert("name".to_owned(), json!(request.name));
            map.insert("grade".to_owned(), json!(request.grade.word()));
            if let Some(address) = &request.address {
                map.insert("address".to_owned(), json!(address));
            }
            Value::Object(map)
        }
        Action::Route(verb) => tools::encode_route(verb),
        // The §8.3 sign-in (REMOTE §8.3, bl-c285): the wall it runs in and the
        // provider row it signs into, and nothing else — the flow is the row's
        // own capability, never a field a seat may spell (DESIGN §8.3 rule 1).
        Action::Login {
            workspace,
            provider,
        } => json!({ "op": LOGIN, "workspace": workspace, "provider": provider }),
    }
}

/// The sign-in act's op token (bl-c285), named once for both directions and
/// for the line that types it.
pub(crate) const LOGIN: &str = "login";

/// The §4.1 pin's two op tokens (bl-b986), named once for the envelope, the
/// line and the help page — which is how one act cannot be spelled three ways.
pub(crate) const PIN: &str = "pin";
pub(crate) const UNPIN: &str = "unpin";

/// Enrollment's op token, named once so the envelope, the line and the help
/// page cannot spell it three ways (REMOTE §1.4 as amended, bl-f4e3).
pub(crate) const ENROLL: &str = "enroll";

/// The three one-shape **conversation** envelopes — op, workspace, agent — said
/// once rather than three times, for [`balls::ball`]'s reason exactly: the
/// gestures that name a conversation and carry nothing else are one shape, and
/// a match arm that rebuilds it is a body pretending to be a row.
fn at_agent(op: &str, workspace: &str, agent: &str) -> Value {
    json!({ "op": op, "workspace": workspace, "agent": agent })
}

/// One grade word read back, or the refusal naming the token (bl-f4e3) — the
/// registry's own table, spent here and by the line, so the two serializations
/// share one vocabulary rather than each carrying a copy.
pub(crate) fn grade_of(word: &str) -> Result<crate::registry::Grade, String> {
    crate::registry::Grade::of(word).ok_or_else(|| format!("unknown grade {word:?}"))
}

/// Decode a deposit envelope. The `op` table is the boundary's whole verb
/// roster; anything else refuses with the offending token.
pub fn decode(v: &Value) -> Result<Gesture, String> {
    let o = v.as_object().ok_or("gesture: not a JSON object")?;
    let op = str_of(o, "op")?;
    match op.as_str() {
        MESSAGE | INTERRUPT => Ok(act(deposited(&op, o)?)),
        "stop" => Ok(act(Action::Stop {
            workspace: str_of(o, "workspace")?,
            agent: str_of(o, "agent")?,
            children: o.get("children").and_then(Value::as_bool).unwrap_or(false),
        })),
        "scan" => Ok(act(Action::Scan {
            workspace: str_of(o, "workspace")?,
        })),
        "nudge" => Ok(act(Action::Nudge {
            workspace: str_of(o, "workspace")?,
            agent: str_of(o, "agent")?,
        })),
        "retarget" => Ok(act(Action::Retarget {
            workspace: str_of(o, "workspace")?,
            agent: str_of(o, "agent")?,
        })),
        // The `bl` family's five (§8.2), each in its family file.
        "close" | "assign" | "release" | "create" | "update" => {
            balls::decode(op.as_str(), o).map(act)
        }
        "prepare" => Ok(act(Action::Prepare {
            workspace: str_of(o, "workspace")?,
            payload: decode_payload(o.get("payload").ok_or("prepare: missing payload")?)?,
        })),
        "prompt" => Ok(act(Action::Prompt {
            prepared: decode_prepared(o.get("prepared").ok_or("prompt: missing prepared")?)?,
            goal: str_of(o, "goal")?,
            seed: fields::opt(o, "seed", fields::u64_of)?,
        })),
        "delete-workspace" => Ok(act(Action::DeleteWorkspace {
            workspace: str_of(o, "workspace")?,
            typed: str_of(o, "typed")?,
        })),
        "delete-agent" => Ok(act(Action::DeleteAgent {
            workspace: str_of(o, "workspace")?,
            agent: str_of(o, "agent")?,
            typed: str_of(o, "typed")?,
        })),
        "arm" | "disarm" | "flag" => monitor::decode(op.as_str(), o),
        fleet::ARM | fleet::DISARM => fleet::decode(op.as_str(), o),
        "answer" => control::decode(o),
        // The §4.9 fifth rung over the §4.11 fold: the floor's two directions.
        "revoke" | "restore" => control::decode_floor(op.as_str(), o),
        "fork" => fork::decode(o).map(act),
        // The §4.10 fan's three: materialize N candidates, retire one, and
        // deliver one (V3.2's acceptance, bl-c2bd).
        fan::FAN | fan::RETIRE | fan::DELIVER => fan::decode(op.as_str(), o).map(act),
        "ack" => Ok(act(Action::Ack)),
        // The §6 decision queue's answer (VISION §5 V5.2): `seen`, not `ack` —
        // the trail's alarm ack already wears that word, and these two quiet
        // different things.
        "seen" => Ok(act(Action::MarkSeen {
            workspace: str_of(o, "workspace")?,
            agent: str_of(o, "agent")?,
        })),
        "clear-trail" => Ok(act(Action::ClearTrail)),
        // The §4.1 pin (bl-b986): the op token IS the direction, so nothing is
        // defaulted and an unpin can never read as a pin that lost a field.
        PIN | UNPIN => Ok(act(Action::Pin {
            workspace: str_of(o, "workspace")?,
            pinned: op == PIN,
        })),
        // Both halves required (REMOTE §8.3): a sign-in that guessed either
        // would write a credential into the wrong sphere, or into the right
        // one for a row nobody named.
        LOGIN => Ok(act(Action::Login {
            workspace: str_of(o, "workspace")?,
            provider: str_of(o, "provider")?,
        })),
        // REMOTE §5's tool-host family (bl-4e08, bl-024b): the presentation,
        // and the routing leg's two halves.
        tools::ADVERTISE | tools::INVOKE | tools::COMPLETE => {
            tools::decode(op.as_str(), o).map(act)
        }
        // REMOTE §1.4's enrollment (bl-f4e3). Every field is required, the
        // grade included: a default here would be a promotion or a demotion
        // nobody typed, and §4.2 forbids the first outright.
        ENROLL => Ok(act(Action::Enroll(crate::registry::enroll::Request {
            workspace: str_of(o, "workspace")?,
            name: str_of(o, "name")?,
            grade: grade_of(&str_of(o, "grade")?)?,
            // …and the one OPTIONAL field (bl-fec6): the address the device
            // will dial, absent when the engine's own is right. A present
            // field must still be a string, so a mistyped one refuses here
            // rather than reaching a QR.
            address: opt_str_of(o, "address")?,
        }))),
        // The two families that read in their own modules (bl-3f46, bl-3746):
        // every query — `config`/`marks` read-shaped among them, bl-0164 —
        // then the §9 config verbs. This match stays the action roster rather
        // than growing three grammars inside it.
        other => query::decode(other, o)
            .map(|query| query.map(Gesture::Ask))
            .or_else(|| config::decode_action(other, o).map(|action| action.map(act)))
            .unwrap_or_else(|| Err(format!("unknown op {other:?}"))),
    }
}

#[cfg(test)]
pub(crate) mod tests;