atman-runtime 1.11.0

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use std::path::{Path, PathBuf};

use crate::git::GitCli;
use crate::git_workspace::{
    WorkspaceBinding, WorkspaceError, WorkspaceFinalizeOutcome, WorkspaceManager, WorkspacePolicy,
    WorkspaceState,
};

#[derive(Debug, Clone)]
pub struct FlowWorkspaceService {
    repository_cwd: PathBuf,
    external_root: Option<PathBuf>,
    daemon_generation: String,
}

impl FlowWorkspaceService {
    pub fn new(
        repository_cwd: impl Into<PathBuf>,
        external_root: Option<PathBuf>,
        daemon_generation: impl Into<String>,
    ) -> Result<Self, WorkspaceError> {
        let daemon_generation = daemon_generation.into();
        if daemon_generation.is_empty() {
            return Err(WorkspaceError::Invalid(
                "daemon generation must be non-empty".into(),
            ));
        }
        Ok(Self {
            repository_cwd: repository_cwd.into(),
            external_root,
            daemon_generation,
        })
    }

    pub fn allocate(
        &self,
        policy: WorkspacePolicy,
        owner_session: &str,
        owner_flow: &str,
        parent_execution_root: Option<&Path>,
    ) -> Result<Option<WorkspaceBinding>, WorkspaceError> {
        if policy == WorkspacePolicy::None {
            return Ok(None);
        }
        let manager = self.manager()?;
        let execution_root = parent_execution_root.unwrap_or(&self.repository_cwd);
        let execution_manager =
            WorkspaceManager::at(execution_root, self.external_root.as_deref())?;
        if execution_manager.repository_root() != manager.repository_root() {
            return Err(WorkspaceError::Invalid(
                "parent execution root belongs to a different repository".into(),
            ));
        }
        let base_oid = GitCli::at(execution_root).head_oid()?;
        let id = workspace_id(owner_flow);
        let record = manager.create_managed(
            &id,
            owner_session,
            owner_flow,
            &self.daemon_generation,
            policy == WorkspacePolicy::Retain,
            &base_oid,
        )?;
        Ok(Some(record.binding()))
    }

    pub fn finalize(
        &self,
        binding: &WorkspaceBinding,
        owner_session: &str,
        owner_flow: &str,
    ) -> Result<WorkspaceFinalizeOutcome, WorkspaceError> {
        let manager = self.manager()?;
        if manager.repository_root() != binding.repository_root {
            return Err(WorkspaceError::Invalid(
                "workspace binding repository mismatch".into(),
            ));
        }
        manager.finalize_managed(&binding.workspace_id, owner_session, owner_flow)
    }

    pub fn persisted_state(&self, binding: &WorkspaceBinding) -> Option<WorkspaceState> {
        let manager = self.manager().ok()?;
        if manager.repository_root() != binding.repository_root {
            return None;
        }
        manager
            .get(&binding.workspace_id)
            .ok()
            .map(|record| record.lifecycle_state())
    }

    fn manager(&self) -> Result<WorkspaceManager, WorkspaceError> {
        WorkspaceManager::at(&self.repository_cwd, self.external_root.as_deref())
    }
}

fn workspace_id(owner_flow: &str) -> String {
    format!("flow-{owner_flow}")
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;
    use std::process::Command;

    use super::*;
    use crate::git_workspace::WorkspaceState;

    fn git(cwd: &Path, args: &[&str]) -> String {
        let output = Command::new("git")
            .args(args)
            .current_dir(cwd)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap().trim().to_owned()
    }

    fn repo() -> tempfile::TempDir {
        let tmp = tempfile::tempdir().unwrap();
        git(tmp.path(), &["init", "-q"]);
        git(tmp.path(), &["config", "user.name", "Atman Test"]);
        git(
            tmp.path(),
            &["config", "user.email", "atman@example.invalid"],
        );
        git(tmp.path(), &["config", "commit.gpgsign", "false"]);
        fs::write(tmp.path().join("README.md"), "committed\n").unwrap();
        git(tmp.path(), &["add", "README.md"]);
        git(tmp.path(), &["commit", "-q", "-m", "initial"]);
        tmp
    }

    #[test]
    fn none_policy_does_not_require_a_repository() {
        let service = FlowWorkspaceService::new("missing", None, "generation").unwrap();
        assert_eq!(
            service
                .allocate(WorkspacePolicy::None, "session", "flow", None)
                .unwrap(),
            None
        );
    }

    #[test]
    fn auto_workspace_persists_trusted_lease_and_releases_cleanly() {
        let repo = repo();
        let service = FlowWorkspaceService::new(repo.path(), None, "generation-one").unwrap();
        let binding = service
            .allocate(WorkspacePolicy::Auto, "session", "0195-flow", None)
            .unwrap()
            .unwrap();
        let manager = WorkspaceManager::at(repo.path(), None).unwrap();
        let record = manager.get(&binding.workspace_id).unwrap();
        assert_eq!(record.owner_session.as_deref(), Some("session"));
        assert_eq!(record.owner_flow.as_deref(), Some("0195-flow"));
        assert_eq!(record.lifecycle_state(), WorkspaceState::Active);
        assert_eq!(
            record
                .lease
                .as_ref()
                .map(|lease| lease.daemon_generation.as_str()),
            Some("generation-one")
        );

        let outcome = service.finalize(&binding, "session", "0195-flow").unwrap();
        assert!(matches!(outcome, WorkspaceFinalizeOutcome::Released(_)));
        assert!(!binding.path.exists());
        assert!(matches!(
            service.finalize(&binding, "session", "0195-flow").unwrap(),
            WorkspaceFinalizeOutcome::AlreadyReleased(_)
        ));
    }

    #[test]
    fn nested_workspace_starts_from_parent_execution_head() {
        let repo = repo();
        let original_cwd = std::env::current_dir().unwrap();
        let main_head = git(repo.path(), &["rev-parse", "HEAD"]);
        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
        let parent = service
            .allocate(WorkspacePolicy::Retain, "session", "parent", None)
            .unwrap()
            .unwrap();

        fs::write(parent.path.join("parent-marker.txt"), "from parent\n").unwrap();
        git(&parent.path, &["add", "parent-marker.txt"]);
        git(&parent.path, &["commit", "-q", "-m", "parent marker"]);
        let parent_head = git(&parent.path, &["rev-parse", "HEAD"]);

        let child = service
            .allocate(
                WorkspacePolicy::Retain,
                "session",
                "child",
                Some(&parent.path),
            )
            .unwrap()
            .unwrap();
        let child_head = git(&child.path, &["rev-parse", "HEAD"]);
        let manager = WorkspaceManager::at(repo.path(), None).unwrap();
        let child_record = manager.get(&child.workspace_id).unwrap();

        assert_eq!(child_head, parent_head);
        assert_eq!(
            child_record.allocation_base.as_deref(),
            Some(parent_head.as_str())
        );
        assert_eq!(child.repository_root, parent.repository_root);
        assert_eq!(child.repository_root, manager.repository_root());
        assert_eq!(
            fs::read_to_string(child.path.join("parent-marker.txt")).unwrap(),
            "from parent\n"
        );
        assert_eq!(git(repo.path(), &["rev-parse", "HEAD"]), main_head);
        assert_eq!(std::env::current_dir().unwrap(), original_cwd);
    }

    #[test]
    fn dirty_and_retained_workspaces_are_preserved() {
        let repo = repo();
        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
        let dirty = service
            .allocate(WorkspacePolicy::Auto, "session", "dirty", None)
            .unwrap()
            .unwrap();
        fs::write(dirty.path.join("dirty.txt"), "inspect me\n").unwrap();
        assert!(matches!(
            service.finalize(&dirty, "session", "dirty").unwrap(),
            WorkspaceFinalizeOutcome::Dirty(_)
        ));
        assert!(dirty.path.exists());

        let retained = service
            .allocate(WorkspacePolicy::Retain, "session", "retained", None)
            .unwrap()
            .unwrap();
        assert!(matches!(
            service.finalize(&retained, "session", "retained").unwrap(),
            WorkspaceFinalizeOutcome::Retained(_)
        ));
        assert!(retained.path.exists());
    }

    #[test]
    fn owner_mismatch_and_unconfigured_bare_repository_are_rejected() {
        let repo = repo();
        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
        let binding = service
            .allocate(WorkspacePolicy::Auto, "session", "owned", None)
            .unwrap()
            .unwrap();
        assert!(service.finalize(&binding, "other", "owned").is_err());
        assert!(binding.path.exists());

        let bare_parent = tempfile::tempdir().unwrap();
        let bare = bare_parent.path().join("repo.git");
        git(
            repo.path(),
            &["clone", "-q", "--bare", ".", bare.to_str().unwrap()],
        );
        let bare_service = FlowWorkspaceService::new(&bare, None, "generation").unwrap();
        assert!(
            bare_service
                .allocate(WorkspacePolicy::Auto, "session", "bare", None)
                .is_err()
        );
    }
}