asobi 0.6.1

A persistent, project-local knowledge graph CLI for AI agents.
Documentation
use std::process::Command;
use std::sync::{Arc, Barrier};
use std::thread;
use tempfile::tempdir;

fn asobi() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_asobi"))
}

#[test]
fn task_dispatcher_commands_and_help_work() {
    for args in [
        vec!["--help"],
        vec!["tasks", "--help"],
        vec!["tasks", "plan", "--help"],
        vec!["tasks", "list", "--help"],
        vec!["tasks", "dispatch", "--help"],
        vec!["tasks", "sync", "--help"],
        vec!["tasks", "close", "--help"],
    ] {
        let output = Command::new(asobi()).args(args).output().unwrap();
        assert!(output.status.success(), "help failed: {output:?}");
    }

    let dir = tempdir().unwrap();
    let db = dir.path().join("tasks.db");
    let db = db.to_str().unwrap();

    let output = Command::new(asobi())
        .args([
            "tasks",
            "plan",
            "asobi:cli",
            "--objective",
            "Add task dispatching",
            "--task",
            "Implement commands",
            "--task",
            "Add integration tests",
        ])
        .env("ASOBI_DATABASE_URL", db)
        .output()
        .unwrap();
    assert!(output.status.success(), "plan failed: {output:?}");

    let output = Command::new(asobi())
        .args(["tasks", "list", "asobi:cli"])
        .env("ASOBI_DATABASE_URL", db)
        .output()
        .unwrap();
    assert!(output.status.success());
    let graph: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    let task = graph["entities"]
        .as_array()
        .unwrap()
        .iter()
        .find(|entity| entity["name"] == "asobi:cli:task-1")
        .unwrap();
    assert_eq!(task["truths"]["status"], "READY_TO_DISPATCH");

    let output = Command::new(asobi())
        .args(["tasks", "dispatch"])
        .env("ASOBI_DATABASE_URL", db)
        .output()
        .unwrap();
    assert!(output.status.success(), "dispatch failed: {output:?}");

    let output = Command::new(asobi())
        .args([
            "tasks",
            "sync",
            "asobi:cli:task-1",
            "--note",
            "implementation complete",
            "--status",
            "DONE",
        ])
        .env("ASOBI_DATABASE_URL", db)
        .output()
        .unwrap();
    assert!(output.status.success(), "sync failed: {output:?}");
}

#[test]
fn only_one_concurrent_dispatcher_claims_a_task() {
    let dir = tempdir().unwrap();
    let db = dir.path().join("contended-tasks.db");
    let db = db.to_str().unwrap().to_owned();
    let output = Command::new(asobi())
        .args([
            "tasks",
            "plan",
            "asobi:contention",
            "--objective",
            "Claim exactly once",
            "--task",
            "One winner",
        ])
        .env("ASOBI_DATABASE_URL", &db)
        .output()
        .unwrap();
    assert!(output.status.success(), "plan failed: {output:?}");

    let workers = 8;
    let barrier = Arc::new(Barrier::new(workers));
    let db = Arc::new(db);
    let mut handles = Vec::new();
    for index in 0..workers {
        let barrier = Arc::clone(&barrier);
        let db = Arc::clone(&db);
        handles.push(thread::spawn(move || {
            barrier.wait();
            Command::new(asobi())
                .args(["tasks", "dispatch", "--agent", &format!("agent-{index}")])
                .env("ASOBI_DATABASE_URL", db.as_ref())
                .output()
                .unwrap()
                .status
                .success()
        }));
    }

    let winners = handles
        .into_iter()
        .map(|handle| handle.join().unwrap())
        .filter(|success| *success)
        .count();
    assert_eq!(winners, 1, "exactly one agent must claim the task");
}