liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! Plan invariants, durable history and current-first delivery on real actors.

use super::{Plan, StepStatus};
use crate::services::lifecycle::site::{
    SiteChange,
    test_support::{Fixture, TestResult, limits, spec},
};

#[test]
fn one_doing_step_and_finished_ordinals_are_preserved() -> TestResult {
    let limits = limits()?;
    let mut plan = Plan::default();
    plan.set(&["Design".into(), "Build".into()], "builder", 1, &limits)?;
    plan.step(1, StepStatus::Doing, None, "builder", 2, &limits)?;
    let before = serde_json::to_value(&plan)?;
    let refused = plan
        .step(2, StepStatus::Doing, None, "builder", 3, &limits)
        .err()
        .ok_or("two doing steps admitted")?;
    assert_eq!(refused.to_string(), "step 1 is already doing");
    assert_eq!(serde_json::to_value(&plan)?, before);
    plan.step(1, StepStatus::Done, None, "builder", 4, &limits)?;
    let refused = plan
        .set(&["Changed".into()], "builder", 5, &limits)
        .err()
        .ok_or("done ordinal removed")?;
    assert_eq!(
        refused.to_string(),
        "done step 1 cannot be removed or renamed"
    );
    assert_eq!(plan.revision, 3);
    assert_eq!(plan.transitions.len(), 3);
    Ok(())
}

#[test]
fn builder_plan_is_durable_and_watchers_read_current_before_changes() -> TestResult {
    let mut fixture = Fixture::new()?;
    fixture.execute("create", SiteChange::CreateSite(spec()))?;
    fixture.execute(
        "bind",
        SiteChange::BindBuilder {
            agent: "builder".into(),
        },
    )?;
    let titles = vec!["Design".into(), "Build".into()];
    fixture.lifecycle.plan_set("echo", "builder", &titles, 20)?;
    let mut watch = fixture
        .lifecycle
        .subscribe_plan(11, "services.echo.plan", &[], None)?;
    let initial = watch
        .try_next()
        .ok_or("current plan missing before changes")?;
    let initial: serde_json::Value = serde_json::from_slice(&initial.payload)?;
    assert_eq!(initial["revision"], 1);
    fixture.lifecycle.plan_step(
        "echo",
        "builder",
        1,
        StepStatus::Doing,
        Some("Writing contracts".into()),
        21,
    )?;
    let next = watch.try_next().ok_or("plan change not delivered")?;
    let next: serde_json::Value = serde_json::from_slice(&next.payload)?;
    assert_eq!(next["revision"], 2);
    assert_eq!(next["current"], 1);
    let refused = fixture
        .lifecycle
        .plan_set("echo", "assistant", &titles, 22)
        .err()
        .ok_or("assistant wrote a plan")?;
    assert_eq!(
        refused.to_string(),
        "pass holder assistant is not the builder of echo"
    );
    assert!(!watch.has_pending());
    let before = fixture.lifecycle.record("echo")?;
    drop(watch);
    let directory = std::mem::replace(&mut fixture.directory, tempfile::tempdir()?);
    let issuer = fixture.issuer.clone();
    drop(fixture);
    let reopened = crate::services::lifecycle::Lifecycle::open(
        &[],
        directory.path().join("services"),
        "fixture-network".into(),
        Some(issuer),
    )?;
    let scheduler = liminal::channel::ChannelSupervisor::new()?;
    let retained = crate::agents::retained::Retained::new(
        crate::agents::config::AgentsConfig {
            max_event_bytes: 65536,
            ..crate::agents::config::AgentsConfig::default()
        },
        scheduler.clone(),
        "fixture",
    );
    reopened.configure_sites(
        limits()?,
        crate::services::lifecycle::site::test_support::open_store(
            &directory.path().join("store"),
        )?,
        None,
        std::sync::Arc::new(crate::services::lifecycle::site::test_support::Issuer::default()),
        retained,
    )?;
    assert_eq!(reopened.record("echo")?, before);
    assert_eq!(reopened.record("echo")?.plan.revision, 2);
    assert_eq!(reopened.record("echo")?.plan.transitions.len(), 2);
    scheduler.shutdown();
    Ok(())
}

#[test]
fn plan_capacity_refuses_without_discarding_history() -> TestResult {
    let mut limits = limits()?;
    limits.transitions = std::num::NonZeroUsize::new(1).ok_or("bad limit")?;
    let mut plan = Plan::default();
    plan.set(&["One".into()], "builder", 1, &limits)?;
    assert!(
        plan.step(1, StepStatus::Done, None, "builder", 2, &limits)
            .is_err()
    );
    assert_eq!(plan.revision, 1);
    assert_eq!(plan.transitions.len(), 1);
    assert_eq!(plan.steps[0].status, StepStatus::Todo);
    Ok(())
}

#[test]
fn malformed_restored_ordinals_and_history_refuse_before_serving() -> TestResult {
    let limits = limits()?;
    let mut plan = Plan::default();
    plan.set(&["One".into()], "builder", 1, &limits)?;
    plan.validate(&limits)?;
    let mut invalid = plan.clone();
    invalid.steps[0].id = 0;
    assert_eq!(
        invalid
            .validate(&limits)
            .err()
            .ok_or("zero ordinal restored")?
            .to_string(),
        "site_invalid: stored plan ordinal"
    );
    let mut invalid = plan.clone();
    invalid.current = Some(1);
    assert!(invalid.validate(&limits).is_err());
    plan.transitions.clear();
    assert!(plan.validate(&limits).is_err());
    Ok(())
}

#[test]
fn completion_is_refused_while_a_plan_step_is_not_done() -> TestResult {
    let fixture = Fixture::new()?;
    fixture.execute("create", SiteChange::CreateSite(spec()))?;
    fixture.execute(
        "bind",
        SiteChange::BindBuilder {
            agent: "builder".into(),
        },
    )?;
    let titles = vec!["Design".into(), "Build".into(), "Prove".into()];
    fixture.lifecycle.plan_set("echo", "builder", &titles, 20)?;
    fixture
        .lifecycle
        .plan_step("echo", "builder", 1, StepStatus::Done, None, 21)?;
    fixture
        .lifecycle
        .plan_step("echo", "builder", 3, StepStatus::Blocked, None, 22)?;
    let outcome = fixture.execute("complete", SiteChange::Complete)?;
    let lys_pass::drafts::Execution::RefusedOnExecution { reason, .. } = outcome else {
        return Err("a site completed with steps not done".into());
    };
    assert_eq!(
        reason,
        "plan steps not done: 2, 3; mark them done before completing"
    );
    for step in [2, 3] {
        fixture
            .lifecycle
            .plan_step("echo", "builder", step, StepStatus::Done, None, 23)?;
    }
    // A finished plan no longer stands in the way; the next condition is the template's.
    let outcome = fixture.execute("complete-again", SiteChange::Complete)?;
    let lys_pass::drafts::Execution::RefusedOnExecution { reason, .. } = outcome else {
        return Err("a site completed without a template".into());
    };
    assert_eq!(reason, "site_invalid: site has no approved template");
    Ok(())
}