yog 0.0.30

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The §16.3 knob and the §9.4 pick, driven through the chokepoint (bl-3f46):
//! a recorder `bl` for the knob's `conf` writes and reads, a real-git workspace
//! and a recorder `litany` for the pick's two halves.

use super::{ACME, deps_at, fire, quiet, script, seed_wall};
use crate::boundary::Action;
use crate::boundary::config::Read;
use crate::boundary::reply::Reply;
use crate::git_tree::tests::fixture::Fixture;
use crate::test_support::TEMPLATE_PROVIDERS;
use std::fs;
use std::path::Path;
use tempfile::tempdir;

#[test]
fn set_marks_answers_with_the_branch_it_read_back_and_logs_the_write() {
    let root = tempdir().unwrap();
    let deps = quiet(root.path());
    let ws = root.path().join("workspaces").join("home");
    let deps = super::seeing(&deps, &[ws.as_path()]);
    let reply = fire(
        &deps,
        &Action::SetMarks {
            workspace: crate::naming::leaf(&ws),
            branch: "balls/agents/home".to_owned(),
        },
    );
    assert_eq!(
        reply,
        Ok(Reply::Marks {
            branch: "balls/agents/home".to_owned(),
        })
    );
    // The write is on the trail (§4.2) as the non-spawn step it is — no `bl`
    // was run, because the value's home is the space's own balls config.
    let logged = crate::opslog::tail(&deps.state_root, 10);
    assert_eq!(logged.len(), 1);
    assert_eq!(logged[0].argv, ["yog-step", "marks", "balls/agents/home"]);
}

#[test]
fn reading_marks_never_refuses_even_for_a_workspace_with_no_project() {
    let root = tempdir().unwrap();
    let deps = quiet(root.path());
    let ws = root.path().join("nowhere");
    let deps = super::seeing(&deps, &[ws.as_path()]);
    let reply = super::ask(
        &deps,
        &crate::boundary::Query::Config(Read::Marks {
            workspace: crate::naming::leaf(&ws),
        }),
    );
    // The launched-then-told-to-work-on-a-project case: nothing is primed,
    // nothing is bound, and the agent still has a branch — balls' default.
    assert_eq!(
        reply,
        Ok(Reply::Marks {
            branch: crate::world::marks::SHARED_BRANCH.to_owned(),
        })
    );
}

#[test]
fn set_marks_refuses_an_unlawful_branch_rather_than_writing_one() {
    let root = tempdir().unwrap();
    let home = root.path().join("workspaces").join("home");
    let deps = super::seeing(&quiet(root.path()), &[home.as_path()]);
    let err = fire(
        &deps,
        &Action::SetMarks {
            workspace: crate::naming::leaf(&home),
            branch: "balls/config".to_owned(),
        },
    )
    .unwrap_err();
    assert!(err.contains("landing branch"), "{err}");
}

/// A real-git workspace carrying litany's own `providers.yaml` on
/// `config/default` — what the pick reads before it rewrites it.
pub(super) fn workspace() -> Fixture {
    let fx = Fixture::new();
    fx.commit_other("providers.yaml", TEMPLATE_PROVIDERS);
    fx
}

pub(super) fn pick(role: &str, provider: &str, model: &str, ws: &Path) -> Action {
    Action::PickModel {
        workspace: crate::naming::leaf(ws),
        role: role.to_owned(),
        provider: provider.to_owned(),
        model: model.to_owned(),
    }
}

/// bl-d9cb. The pick is ONE write: `providers.yaml`, staged for litany's own
/// commit. The `models.yaml` half it used to land first — normatively first,
/// because litany's cross-check refused a config naming an undeclared model —
/// reached a table litany retired (its bl-35e2), so the file must come out of a
/// pick untouched, and the assertion has to be that it was never created.
#[test]
fn a_pick_commits_the_assignment_and_writes_no_second_file() {
    let root = tempdir().unwrap();
    let bin = tempdir().unwrap();
    let log = bin.path().join("log");
    let litany = script(
        bin.path(),
        "litany",
        &format!(
            "cat \"$YOG_EDIT_SRC/providers.yaml\" > {}\nexit 0\n",
            log.display()
        ),
    );
    let fx = workspace();
    let deps = super::seeing(
        &deps_at(root.path(), &litany, Path::new("/no/bl")),
        &[fx.path.as_path()],
    );
    // The pick's provider gate reads the rows of the workspace being picked
    // for (bl-fcd5), so `acme` has to be live in *that* sphere's wall.
    seed_wall(&deps, &fx.path, ACME);
    let reply = fire(&deps, &pick("worker", "acme", "m-9", &fx.path));
    assert!(
        matches!(&reply, Ok(Reply::Outcome(o)) if o.ok()),
        "{reply:?}"
    );
    let staged = fs::read_to_string(&log).unwrap();
    assert!(staged.contains("provider: acme"), "{staged}");
    assert!(staged.contains("model: m-9"), "{staged}");
    assert!(
        !root.path().join("litany/models.yaml").exists(),
        "a pick declares nothing in litany's global config"
    );
}

#[test]
fn a_pick_on_a_row_brazen_lacks_writes_nothing() {
    let root = tempdir().unwrap();
    let fx = workspace();
    let deps = super::seeing(&quiet(root.path()), &[fx.path.as_path()]);
    let err = fire(&deps, &pick("worker", "nope", "m-9", &fx.path)).unwrap_err();
    assert!(err.contains("no provider row `nope`"), "{err}");
    assert!(!root.path().join("litany/models.yaml").exists());
}

#[test]
fn a_pick_needs_a_lineage_it_can_read_the_assignment_from() {
    let root = tempdir().unwrap();
    let deps = quiet(root.path());
    let err = fire(&deps, &pick("worker", "acme", "m-9", root.path())).unwrap_err();
    assert!(!err.is_empty(), "{err}");
}

#[test]
fn a_pick_whose_models_file_cannot_be_read_refuses_before_planning() {
    let root = tempdir().unwrap();
    let deps = quiet(root.path());
    fs::create_dir_all(root.path().join("litany/models.yaml")).unwrap();
    let fx = workspace();
    assert!(fire(&deps, &pick("worker", "acme", "m-9", &fx.path)).is_err());
}

/// bl-3d22, at the executor every seat crosses — `/model`, a deposit and the
/// pane's click are one implementation. `claude-code` is a row brazen SHIPS, so
/// the row gate passed it and both config halves advanced; the next worker start
/// then died inside brazen's encoder before any network call. Neither half is
/// written now, and the refusal names the protocol rather than the row name.
#[test]
fn a_pick_on_a_tool_less_protocol_writes_neither_half() {
    let root = tempdir().unwrap();
    let fx = workspace();
    let deps = super::seeing(&quiet(root.path()), &[fx.path.as_path()]);
    let err = fire(&deps, &pick("worker", "claude-code", "sonnet", &fx.path)).unwrap_err();
    assert!(err.contains("`claude-code` cannot serve a role"), "{err}");
    assert!(err.contains("claude_code declares no tools"), "{err}");
    assert!(!root.path().join("litany/models.yaml").exists());
}