meerkat-mobkit 0.8.21

Companion orchestration platform for the Meerkat multi-agent runtime
#![allow(
    clippy::expect_used,
    clippy::unwrap_used,
    clippy::panic,
    clippy::uninlined_format_args,
    clippy::collapsible_if,
    clippy::redundant_clone,
    clippy::needless_raw_string_hashes,
    clippy::single_match,
    clippy::redundant_closure_for_method_calls,
    clippy::redundant_pattern_matching,
    clippy::ignored_unit_patterns,
    clippy::clone_on_copy,
    clippy::manual_assert,
    clippy::unwrap_in_result,
    clippy::useless_vec
)]
use std::{fs, process::Command, time::Duration};

use meerkat_mobkit::{
    BaselineRuntimeError, DiscoverySpec, MEERKAT_REPO_ENV, MobKitConfig, ModuleConfig,
    NormalizationError, PreSpawnData, ProcessBoundaryError, ProtocolParseError,
    REQUIRED_MEERKAT_SYMBOLS, RestartPolicy, RpcRuntimeError, RuntimeBoundaryError, UnifiedEvent,
    parse_module_event_line, run_discovered_module_once, run_meerkat_baseline_verification_once,
    run_module_boundary_once, run_rpc_capabilities_boundary_once,
};
use serde::Deserialize;
use serde_json::json;

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ReadyPayload {
    version: String,
}

fn shell_module(id: &str, script: &str) -> ModuleConfig {
    ModuleConfig {
        id: id.to_string(),
        command: "sh".to_string(),
        args: vec!["-c".to_string(), script.to_string()],
        restart_policy: RestartPolicy::Never,
    }
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_valid_json_line_from_subprocess_parses() {
    let config = MobKitConfig {
        modules: vec![shell_module(
            "echo",
            r#"printf '%s\n' '{"event_id":"evt-1","source":"module","timestamp_ms":42,"event":{"kind":"module","module":"echo","event_type":"ready","payload":{"version":"1.0.0"}}}'"#,
        )],
        discovery: DiscoverySpec {
            namespace: "default".to_string(),
            modules: vec!["echo".to_string()],
        },
        pre_spawn: vec![PreSpawnData {
            module_id: "echo".to_string(),
            env: vec![("RUST_LOG".to_string(), "debug".to_string())],
        }],
    };

    let envelope = run_discovered_module_once(&config, "echo", Duration::from_secs(1))
        .expect("valid process output should parse");
    assert_eq!(envelope.event_id, "evt-1");
    assert_eq!(envelope.source, "module");
    assert_eq!(envelope.timestamp_ms, 42);
    assert_eq!(
        envelope.event,
        UnifiedEvent::Module(meerkat_mobkit::ModuleEvent {
            module: "echo".to_string(),
            event_type: "ready".to_string(),
            payload: json!({"version":"1.0.0"}),
        })
    );
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_invalid_schema_from_subprocess_rejected() {
    let module = shell_module(
        "bad",
        r#"printf '%s\n' '{"source":"module","timestamp_ms":42,"event":{"kind":"module"}}'"#,
    );

    let err = run_module_boundary_once(&module, None, Duration::from_secs(1))
        .expect_err("schema must reject");
    assert_eq!(
        err,
        RuntimeBoundaryError::Normalize(NormalizationError::MissingField("event_id"))
    );
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_timeout_on_never_responding_subprocess() {
    let module = shell_module("sleepy", "sleep 5");
    let err = run_module_boundary_once(&module, None, Duration::from_millis(25))
        .expect_err("must timeout");
    assert_eq!(
        err,
        RuntimeBoundaryError::Process(ProcessBoundaryError::Timeout { timeout_ms: 25 })
    );
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_normalization_path_over_mixed_subprocess_outputs() {
    let agent = shell_module(
        "agent-line",
        r#"printf '%s\n' '{"event_id":"evt-agent","source":"agent","timestamp_ms":1,"agent_id":"a-1","event_type":"tick"}'"#,
    );
    let module = shell_module(
        "module-line",
        r#"printf '%s\n' '{"event_id":"evt-module","source":"module","timestamp_ms":2,"module":"mod-1","event_type":"ready","payload":{"ok":true}}'"#,
    );

    let first = run_module_boundary_once(&agent, None, Duration::from_secs(1)).expect("agent line");
    let second =
        run_module_boundary_once(&module, None, Duration::from_secs(1)).expect("module line");

    assert_eq!(first.event_id, "evt-agent");
    assert_eq!(first.source, "agent");
    assert_eq!(first.timestamp_ms, 1);
    assert_eq!(
        first.event,
        UnifiedEvent::Agent {
            agent_id: "a-1".to_string(),
            event_type: "tick".to_string(),
            payload: None,
        }
    );

    assert_eq!(second.event_id, "evt-module");
    assert_eq!(second.source, "module");
    assert_eq!(second.timestamp_ms, 2);
    assert_eq!(
        second.event,
        UnifiedEvent::Module(meerkat_mobkit::ModuleEvent {
            module: "mod-1".to_string(),
            event_type: "ready".to_string(),
            payload: json!({"ok":true}),
        })
    );
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_rpc_capabilities_requires_contract_version_from_process_response() {
    let caps = run_rpc_capabilities_boundary_once(
        "sh",
        &[
            "-c".to_string(),
            r#"printf '%s\n' '{"contract_version":"0.1.0","transport":"stdio"}'"#.to_string(),
        ],
        &[],
        Duration::from_secs(1),
    )
    .expect("process should return capabilities");
    assert_eq!(caps.contract_version, "0.1.0");

    let missing = run_rpc_capabilities_boundary_once(
        "sh",
        &[
            "-c".to_string(),
            r#"printf '%s\n' '{"transport":"stdio"}'"#.to_string(),
        ],
        &[],
        Duration::from_secs(1),
    )
    .expect_err("missing contract_version must fail");
    assert_eq!(
        missing,
        RpcRuntimeError::Capabilities(meerkat_mobkit::RpcCapabilitiesError::MissingContractVersion)
    );
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_meerkat_baseline_symbols_check_against_repo_path() {
    let repo_root = std::env::var(MEERKAT_REPO_ENV)
        .expect("set MEERKAT_REPO to run the external Meerkat baseline check");
    let payload = json!({ "repo_root": repo_root }).to_string();
    let report = run_meerkat_baseline_verification_once(
        "sh",
        &[
            "-c".to_string(),
            "printf '%s\\n' \"$BASELINE_REPO_PAYLOAD\"".to_string(),
        ],
        &[("BASELINE_REPO_PAYLOAD".to_string(), payload)],
        Duration::from_secs(1),
    )
    .expect("baseline symbols should exist");
    assert!(report.missing_symbols.is_empty());
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_meerkat_baseline_missing_symbols_has_typed_diagnostics() {
    let temp = tempfile::tempdir().expect("temp dir");
    let err = run_meerkat_baseline_verification_once(
        "sh",
        &[
            "-c".to_string(),
            format!(
                "printf '%s\\n' '{{\"repo_root\":\"{}\"}}'",
                temp.path().display()
            ),
        ],
        &[],
        Duration::from_secs(1),
    )
    .expect_err("empty repo should fail");
    assert!(matches!(err, BaselineRuntimeError::Baseline(_)));
}

#[test]
#[ignore = "external boundary subprocess check"]
fn external_unexpected_payload_from_subprocess_is_rejected() {
    let line = run_module_boundary_once(
        &shell_module(
            "bad-payload",
            r#"printf '%s\n' '{"event_id":"evt-x","source":"module","timestamp_ms":42,"event":{"kind":"module","module":"echo","event_type":"ready","payload":{"unexpected":true}}}'"#,
        ),
        None,
        Duration::from_secs(1),
    )
    .expect("line should normalize");

    let envelope = serde_json::to_string(&line).expect("serialize normalized envelope");
    let err = parse_module_event_line::<ReadyPayload>(&envelope, "ready")
        .expect_err("typed payload mismatch should fail");
    assert_eq!(err, ProtocolParseError::UnexpectedPayloadType);
}

#[test]
fn phase0_baseline_check_binary_requires_configuration() {
    let output = Command::new(env!("CARGO_BIN_EXE_baseline_check"))
        .env_remove(MEERKAT_REPO_ENV)
        .output()
        .expect("phase0 baseline binary should run");

    assert!(
        !output.status.success(),
        "unconfigured phase0_baseline_check unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("not configured"));
    assert!(stderr.contains("MEERKAT_REPO"));
}

#[test]
fn phase0_baseline_check_binary_uses_configured_repo() {
    let repo = tempfile::tempdir().expect("temporary Meerkat repository");
    fs::write(
        repo.path().join("baseline-symbols.rs"),
        REQUIRED_MEERKAT_SYMBOLS.join("\n"),
    )
    .expect("write baseline symbols");

    let output = Command::new(env!("CARGO_BIN_EXE_baseline_check"))
        .env(MEERKAT_REPO_ENV, repo.path())
        .output()
        .expect("phase0 baseline binary should run");

    assert!(
        output.status.success(),
        "configured phase0_baseline_check failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains(&format!("repo={}", repo.path().display())));
    assert!(stdout.contains("missing_symbols="));
}