ai-dispatch 11.2.0

Multi-AI CLI team orchestrator
// Read-only settlement compares the existing dispatch snapshot without recovery writes.
// Exports settle to dirty-worktree settlement; violations preserve the checkout.
// Deps: worktree snapshot, Store events, and persisted dispatch baseline.

use anyhow::{Context, Result};
use std::{collections::BTreeSet, path::Path};
use crate::{store::Store, types::{EventKind, TaskEvent, TaskId}};
use super::{DirtyWorktreeAction, RunArgs};

pub(in crate::cmd::run) fn capture_baseline(store: &Store, task_id: &TaskId, dir: Option<&str>) -> Result<Option<Vec<String>>> {
    let dir = dir.context("Read-only run directory is unavailable")?;
    if !is_git_directory(dir)? {
        let detail = format!("Warning: read-only enforcement unavailable in {dir}: not a git repository. Enforcement covers Git run directories only.");
        aid_warn!("[aid] {detail}");
        store.insert_event(&TaskEvent {
            task_id: task_id.clone(), timestamp: chrono::Local::now(),
            event_kind: EventKind::Setup, detail, metadata: Some(serde_json::json!({"warning": true})),
        })?;
        return Ok(None);
    }
    Ok(Some(crate::workspace::worktree::capture_worktree_snapshot(Path::new(dir))?.read_only_state(Path::new(dir))?))
}

fn is_git_directory(dir: &str) -> Result<bool> {
    let output = std::process::Command::new("git").current_dir(dir).env("LC_ALL", "C")
        .args(["rev-parse", "--show-toplevel"]).output().context("Failed to resolve Git run directory")?;
    if output.status.success() { return Ok(true); }
    let reason = String::from_utf8_lossy(&output.stderr);
    if reason.starts_with("fatal: not a git repository (or any of the parent directories)") { return Ok(false); }
    anyhow::bail!("Git run directory resolution failed: {reason}")
}

pub(super) fn settle(
    store: &Store,
    task_id: &TaskId,
    args: &RunArgs,
    dir: &str,
    baseline: Option<&[String]>,
) -> Result<DirtyWorktreeAction> {
    let comparison = (|| {
        if baseline.is_none() && !is_git_directory(dir)? {
            return Ok((Vec::new(), Vec::new()));
        }
        let baseline = baseline.context("Dispatch snapshot is unavailable")?;
        let snapshot = crate::workspace::worktree::capture_worktree_snapshot(Path::new(dir))?;
        let current = snapshot.read_only_state(Path::new(dir))?;
        changed_paths(dir, [args.result_file.as_deref(), args.output.as_deref()], baseline, &current)
    })();
    let detail = match comparison {
        Ok((paths, refs)) => {
            record_ref_diagnostic(store, task_id, &refs)?;
            if paths.is_empty() { return Ok(DirtyWorktreeAction::Continue); }
            format!("Read-only violation: changed paths: {}. Files left unchanged for review.", paths.join(", "))
        }
        Err(err) => format!("Read-only enforcement failed in {dir}: {err:#}. Files left unchanged for review."),
    };
    aid_warn!("[aid] {detail}");
    store.insert_agent_event(&TaskEvent {
        task_id: task_id.clone(), timestamp: chrono::Local::now(),
        event_kind: EventKind::Error, detail, metadata: None,
    })?;
    crate::task::lifecycle::mark_failed(store, task_id)?;
    Ok(DirtyWorktreeAction::Failed)
}

fn record_ref_diagnostic(store: &Store, task_id: &TaskId, refs: &[String]) -> Result<()> {
    if !refs.is_empty() {
        store.insert_event(&TaskEvent {
            task_id: task_id.clone(), timestamp: chrono::Local::now(), event_kind: EventKind::Setup,
            detail: format!("Read-only diagnostic: unrelated refs changed: {}", refs.join(", ")),
            metadata: Some(serde_json::json!({"diagnostic": "unrelated_ref_changes", "refs": refs})),
        })?;
    }
    Ok(())
}

fn changed_paths(
    dir: &str,
    artifacts: [Option<&str>; 2],
    baseline: &[String],
    current: &[String],
) -> Result<(Vec<String>, Vec<String>)> {
    let before: BTreeSet<_> = baseline.iter().collect();
    let after: BTreeSet<_> = current.iter().collect();
    let dir = Path::new(dir).canonicalize()?;
    let artifacts: Vec<_> = artifacts.into_iter().flatten().map(|file| {
        let file = dir.join(file);
        match (file.parent().and_then(|parent| parent.canonicalize().ok()), file.file_name()) {
            (Some(parent), Some(name)) => parent.join(name),
            _ => file,
        }
    }).collect();
    let root = std::process::Command::new("git").current_dir(&dir)
        .args(["rev-parse", "--show-toplevel"]).output()?;
    anyhow::ensure!(root.status.success(), "Failed to resolve read-only repository root");
    let root = String::from_utf8(root.stdout)?;
    let root = Path::new(root.trim_end_matches('\n')).canonicalize()?;
    let branches = head_branches(baseline, current)?;
    let mut paths = BTreeSet::new();
    let mut refs = BTreeSet::new();
    for entry in before.symmetric_difference(&after) {
        let (path, _, _, _): (String, String, String, u32) = serde_json::from_str(entry)?;
        if !path.starts_with(".git/")
            && artifacts.iter().any(|file| file.components().eq(root.join(&path).components())) {
            continue;
        }
        if path.starts_with(".git/refs/") && !branches.contains(&path) {
            refs.insert(path);
            continue;
        }
        paths.insert(path);
    }
    Ok((paths.into_iter().collect(), refs.into_iter().collect()))
}

fn head_branches(baseline: &[String], current: &[String]) -> Result<BTreeSet<String>> {
    let mut branches = BTreeSet::new();
    for entry in baseline.iter().chain(current) {
        let (path, _, content, _): (String, String, String, u32) = serde_json::from_str(entry)?;
        if path == ".git/HEAD" && let Some((branch, _)) = content.split_once(':')
            && branch.starts_with("refs/heads/") {
            branches.insert(format!(".git/{branch}"));
        }
    }
    Ok(branches)
}

#[cfg(test)]
#[path = "read_only_ref_tests.rs"]
mod tests;