yoagent-state 0.4.2

Durable state and lineage for long-running agents.
Documentation
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
use yoagent_state::{
    ActorRef, ArtifactRef, Event, EventStore, ExpectedEffect, JsonlEventStore, MemoryEventStore,
    NodeId, PatchId, PatchStatus, Precondition, StateOp, StatePatch, YoAgentState,
    changed_file_ops, parse_git_name_status, replay,
};

#[tokio::test]
async fn appends_events_and_scans_after() {
    let store = MemoryEventStore::new();
    let first = Event::new(ActorRef::agent("test"), "run.started", json!({}));
    let second = Event::new(ActorRef::agent("test"), "run.finished", json!({}));
    let first_id = first.id.clone();

    store.append(vec![first, second.clone()]).await.unwrap();

    let all = store.scan().await.unwrap();
    assert_eq!(all.len(), 2);
    let after = store.scan_after(Some(first_id)).await.unwrap();
    assert_eq!(after.len(), 1);
    assert_eq!(after[0].id, second.id);
}

#[tokio::test]
async fn applies_state_ops_to_graph() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let node = NodeId::new("failure_1");

    state
        .apply_ops(
            ActorRef::agent("test"),
            vec![
                StateOp::CreateNode {
                    id: node.clone(),
                    kind: "failure".to_string(),
                    props: json!({ "title": "timeout" }),
                },
                StateOp::UpdateNode {
                    id: node.clone(),
                    props: json!({ "severity": "high" }),
                },
            ],
        )
        .await
        .unwrap();

    let graph = state.graph().await;
    let stored = graph.get_node(&node).unwrap();
    assert_eq!(stored.kind, "failure");
    assert_eq!(stored.props["title"], "timeout");
    assert_eq!(stored.props["severity"], "high");
    assert_eq!(graph.version, 2);
}

#[tokio::test]
async fn replays_event_log_into_graph() {
    let store = MemoryEventStore::new();
    let node = NodeId::new("task_1");
    let ops = vec![StateOp::CreateNode {
        id: node.clone(),
        kind: "task".to_string(),
        props: json!({ "title": "write docs" }),
    }];

    store
        .append(vec![Event::new(
            ActorRef::agent("test"),
            yoagent_state::STATE_OPS_APPLIED,
            serde_json::to_value(ops).unwrap(),
        )])
        .await
        .unwrap();

    let replayed = replay(&store.scan().await.unwrap()).unwrap();
    assert!(replayed.get_node(&node).is_some());

    let loaded = YoAgentState::load(store).await.unwrap();
    assert!(loaded.graph().await.get_node(&node).is_some());
}

#[tokio::test]
async fn patch_status_transitions_update_patch_node() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let patch_id = PatchId::new("patch_1");
    let mut patch = StatePatch::new(
        patch_id.clone(),
        "Fix timeout retry",
        "Persist retry state",
        ActorRef::agent("test"),
    );
    patch.preconditions.push(Precondition::StateVersionIs(0));
    patch.expected_effects.push(ExpectedEffect::TestPasses {
        name: "retry_timeout".to_string(),
    });

    state.propose_patch(patch).await.unwrap();
    state
        .update_patch_status(patch_id.clone(), PatchStatus::Evaluated, None)
        .await
        .unwrap();
    state
        .update_patch_status(
            patch_id.clone(),
            PatchStatus::Approved,
            Some("test passed".to_string()),
        )
        .await
        .unwrap();

    let node = state
        .get_node(NodeId::new(patch_id.0))
        .await
        .expect("patch node");
    assert_eq!(node.props["status"], "Approved");
    assert_eq!(node.props["status_reason"], "test passed");
}

#[tokio::test]
async fn lineage_query_explains_failure_patch_eval_chain() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let actor = ActorRef::agent("test");
    let failure = NodeId::new("failure_1");
    let patch_id = PatchId::new("patch_1");

    state
        .record_failure(actor.clone(), failure.clone(), "retry fails", "timeout")
        .await
        .unwrap();

    let mut patch = StatePatch::new(
        patch_id.clone(),
        "Fix retry",
        "Persist attempt count",
        actor.clone(),
    );
    patch.evidence.push(failure.clone());
    patch
        .artifacts
        .push(ArtifactRef::new("git.diff", "file://patch.diff"));
    state.propose_patch(patch).await.unwrap();
    state
        .record_eval_result(
            actor,
            NodeId::new("eval_1"),
            patch_id.clone(),
            "cargo test",
            true,
        )
        .await
        .unwrap();
    state
        .record_decision(
            ActorRef::user("reviewer"),
            NodeId::new("decision_1"),
            patch_id.clone(),
            true,
            "eval passed",
        )
        .await
        .unwrap();

    let lineage = state.lineage(NodeId::new(patch_id.0)).await;
    assert!(lineage.outgoing.iter().any(|rel| rel.rel == "addresses"));
    assert!(lineage.outgoing.iter().any(|rel| rel.rel == "validated_by"));
    assert!(lineage.outgoing.iter().any(|rel| rel.rel == "approved_by"));
    assert_eq!(lineage.root.unwrap().artifacts.len(), 1);
}

#[tokio::test]
async fn jsonl_store_survives_reload() {
    let path = std::env::temp_dir().join(format!(
        "yoagent-state-{}.jsonl",
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    let node = NodeId::new("persisted");

    let state = YoAgentState::load(JsonlEventStore::new(&path))
        .await
        .unwrap();
    state
        .apply_ops(
            ActorRef::agent("test"),
            vec![StateOp::CreateNode {
                id: node.clone(),
                kind: "observation".to_string(),
                props: json!({ "title": "survives reload" }),
            }],
        )
        .await
        .unwrap();

    let reloaded = YoAgentState::load(JsonlEventStore::new(&path))
        .await
        .unwrap();
    assert!(reloaded.graph().await.get_node(&node).is_some());

    let _ = tokio::fs::remove_file(path).await;
}

#[test]
fn observer_turns_changed_files_into_patch_relations() {
    let files = parse_git_name_status("M src/lib.rs\nA tests/state_flow.rs\n");
    let ops = changed_file_ops(PatchId::new("patch_1"), &files);

    assert_eq!(files.len(), 2);
    assert_eq!(ops.len(), 4);
}