basis-cli 0.4.0

The basis CLI, over the basis SDK and basis-acp: headless runs, an ACP server, and a websocket bridge. Installs as `basis`.
//! The static ownership policy behind wait, cancel, and hint decisions.
//!
//! ADR-0017's rules, evaluated over `meta.json` parent chains instead of the
//! daemon's journal. The dynamic wait graph is gone (ADR-0019): a wait is a
//! process observing a file, a cycle is two observers, and the finite deadline
//! bounds it — so only the static tree shape is checked here.

use super::{
    data_dir::DataDir,
    state::{MAX_TASKS, TaskMeta, load_meta},
};

fn meta_of(data: &DataDir, task: &str) -> Option<TaskMeta> {
    let paths = data.agent_dir(task)?;
    paths.exists().then(|| load_meta(&paths).ok()).flatten()
}

pub(crate) fn validate_wait_edge(
    data: &DataDir,
    caller: Option<&str>,
    target: &str,
) -> Result<(), String> {
    let Some(caller) = caller else {
        return Ok(());
    };
    if caller == target {
        return Err("a task cannot await itself".to_string());
    }
    if meta_of(data, target).is_none() {
        return Err(format!("task {target} does not exist"));
    }
    if meta_of(data, caller).is_none() {
        return Err(format!("caller task {caller} does not exist"));
    }
    if is_ancestor(data, target, caller) {
        return Err(format!(
            "task {caller} cannot await its ancestor {target}; send without --await instead"
        ));
    }
    if is_ancestor(data, caller, target) {
        return Ok(());
    }
    if root_of(data, caller) == root_of(data, target) {
        return Err(format!(
            "task {caller} cannot await peer {target}; only descendants or independent roots are safe"
        ));
    }
    Ok(())
}

pub(crate) fn validate_cancel_target(
    data: &DataDir,
    caller: Option<&str>,
    target: &str,
) -> Result<(), String> {
    if meta_of(data, target).is_none() {
        return Err(format!("task {target} does not exist"));
    }
    let Some(caller) = caller else {
        return Ok(());
    };
    if meta_of(data, caller).is_none() {
        return Err(format!("caller task {caller} does not exist"));
    }
    if caller == target || is_ancestor(data, caller, target) {
        return Ok(());
    }
    if is_ancestor(data, target, caller) {
        return Err(format!("task {caller} cannot cancel its ancestor {target}"));
    }
    Err(format!(
        "task {caller} cannot cancel peer {target}; only itself or descendants are allowed"
    ))
}

/// Return a next action that is legal for the submitting task. Enqueue-only
/// sends never block; when the target is an ancestor, peer, or self, suggest
/// inspecting the target's inbox instead of an impossible `basis wait` edge.
pub(crate) fn send_next_hint(
    data: &DataDir,
    caller: Option<&str>,
    target: &str,
    message: &str,
) -> String {
    if let Some(caller) = caller
        && validate_wait_edge(data, Some(caller), target).is_err()
    {
        return format!("basis inbox {target}");
    }
    format!("basis wait {target} --message {message}")
}

fn is_ancestor(data: &DataDir, ancestor: &str, descendant: &str) -> bool {
    let mut current = meta_of(data, descendant).and_then(|meta| meta.parent);
    // Bounded walk: corrupt metadata must not become an infinite loop.
    for _ in 0..MAX_TASKS {
        match current {
            Some(id) if id == ancestor => return true,
            Some(id) => current = meta_of(data, &id).and_then(|meta| meta.parent),
            None => return false,
        }
    }
    false
}

fn root_of(data: &DataDir, task: &str) -> String {
    let mut current = task.to_string();
    for _ in 0..MAX_TASKS {
        match meta_of(data, &current).and_then(|meta| meta.parent) {
            Some(parent) => current = parent,
            None => break,
        }
    }
    current
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::local::state::{RunOptions, save_meta};

    pub(crate) fn record(data: &DataDir, task: &str, parent: Option<&str>) {
        let paths = data.agent_dir(task).expect("well-formed handle");
        std::fs::create_dir_all(paths.dir()).unwrap();
        let meta = TaskMeta::new(
            task.to_string(),
            parent.map(str::to_string),
            false,
            "/repo".to_string(),
            "prompt".to_string(),
            RunOptions::default(),
            None,
        );
        save_meta(&paths, &meta).unwrap();
    }

    fn handle(index: u8) -> String {
        format!("0123456789abcdef/{:032x}", index)
    }

    fn tree() -> (tempfile::TempDir, DataDir, [String; 4]) {
        let dir = tempfile::tempdir().unwrap();
        let data = DataDir::from_path(dir.path()).unwrap();
        let [root, child, peer, other] = [handle(1), handle(2), handle(3), handle(4)];
        record(&data, &root, None);
        record(&data, &child, Some(&root));
        record(&data, &peer, Some(&root));
        record(&data, &other, None);
        (dir, data, [root, child, peer, other])
    }

    #[test]
    fn wait_edges_allow_descendants_and_independent_roots_only() {
        let (_dir, data, [root, child, peer, other]) = tree();

        assert!(validate_wait_edge(&data, Some(&root), &child).is_ok());
        assert!(validate_wait_edge(&data, Some(&root), &other).is_ok());
        assert!(validate_wait_edge(&data, Some(&child), &root).is_err());
        assert!(validate_wait_edge(&data, Some(&child), &peer).is_err());
        assert!(validate_wait_edge(&data, Some(&root), &root).is_err());
    }

    #[test]
    fn cancellation_authority_only_flows_down_the_attached_tree() {
        let (_dir, data, [root, child, _peer, other]) = tree();

        let upward = validate_cancel_target(&data, Some(&child), &root)
            .expect_err("a child cannot cancel its owner");
        assert!(upward.contains("ancestor"), "{upward}");

        let sideways = validate_cancel_target(&data, Some(&root), &other)
            .expect_err("an attached task cannot cancel an independent root");
        assert!(sideways.contains("peer"), "{sideways}");

        assert!(validate_cancel_target(&data, Some(&root), &child).is_ok());
        assert!(validate_cancel_target(&data, Some(&root), &root).is_ok());
        assert!(validate_cancel_target(&data, None, &root).is_ok());
    }

    #[test]
    fn send_hint_does_not_recommend_an_impossible_wait() {
        let (_dir, data, [root, child, ..]) = tree();

        assert_eq!(
            send_next_hint(&data, Some(&child), &root, "message-1"),
            format!("basis inbox {root}"),
            "a child must not be told to wait on its ancestor"
        );
        assert_eq!(
            send_next_hint(&data, Some(&root), &child, "message-1"),
            format!("basis wait {child} --message message-1")
        );
        assert_eq!(
            send_next_hint(&data, None, &child, "message-1"),
            format!("basis wait {child} --message message-1")
        );
    }
}