Skip to main content

atman_runtime/
flow_workspace.rs

1use std::path::{Path, PathBuf};
2
3use crate::git::GitCli;
4use crate::git_workspace::{
5    WorkspaceBinding, WorkspaceError, WorkspaceFinalizeOutcome, WorkspaceManager, WorkspacePolicy,
6    WorkspaceState,
7};
8
9#[derive(Debug, Clone)]
10pub struct FlowWorkspaceService {
11    repository_cwd: PathBuf,
12    external_root: Option<PathBuf>,
13    daemon_generation: String,
14}
15
16impl FlowWorkspaceService {
17    pub fn new(
18        repository_cwd: impl Into<PathBuf>,
19        external_root: Option<PathBuf>,
20        daemon_generation: impl Into<String>,
21    ) -> Result<Self, WorkspaceError> {
22        let daemon_generation = daemon_generation.into();
23        if daemon_generation.is_empty() {
24            return Err(WorkspaceError::Invalid(
25                "daemon generation must be non-empty".into(),
26            ));
27        }
28        Ok(Self {
29            repository_cwd: repository_cwd.into(),
30            external_root,
31            daemon_generation,
32        })
33    }
34
35    pub fn allocate(
36        &self,
37        policy: WorkspacePolicy,
38        owner_session: &str,
39        owner_flow: &str,
40        parent_execution_root: Option<&Path>,
41    ) -> Result<Option<WorkspaceBinding>, WorkspaceError> {
42        if policy == WorkspacePolicy::None {
43            return Ok(None);
44        }
45        let manager = self.manager()?;
46        let execution_root = parent_execution_root.unwrap_or(&self.repository_cwd);
47        let execution_manager =
48            WorkspaceManager::at(execution_root, self.external_root.as_deref())?;
49        if execution_manager.repository_root() != manager.repository_root() {
50            return Err(WorkspaceError::Invalid(
51                "parent execution root belongs to a different repository".into(),
52            ));
53        }
54        let base_oid = GitCli::at(execution_root).head_oid()?;
55        let id = workspace_id(owner_flow);
56        let record = manager.create_managed(
57            &id,
58            owner_session,
59            owner_flow,
60            &self.daemon_generation,
61            policy == WorkspacePolicy::Retain,
62            &base_oid,
63        )?;
64        Ok(Some(record.binding()))
65    }
66
67    pub fn finalize(
68        &self,
69        binding: &WorkspaceBinding,
70        owner_session: &str,
71        owner_flow: &str,
72    ) -> Result<WorkspaceFinalizeOutcome, WorkspaceError> {
73        let manager = self.manager()?;
74        if manager.repository_root() != binding.repository_root {
75            return Err(WorkspaceError::Invalid(
76                "workspace binding repository mismatch".into(),
77            ));
78        }
79        manager.finalize_managed(&binding.workspace_id, owner_session, owner_flow)
80    }
81
82    pub fn persisted_state(&self, binding: &WorkspaceBinding) -> Option<WorkspaceState> {
83        let manager = self.manager().ok()?;
84        if manager.repository_root() != binding.repository_root {
85            return None;
86        }
87        manager
88            .get(&binding.workspace_id)
89            .ok()
90            .map(|record| record.lifecycle_state())
91    }
92
93    fn manager(&self) -> Result<WorkspaceManager, WorkspaceError> {
94        WorkspaceManager::at(&self.repository_cwd, self.external_root.as_deref())
95    }
96}
97
98fn workspace_id(owner_flow: &str) -> String {
99    format!("flow-{owner_flow}")
100}
101
102#[cfg(test)]
103mod tests {
104    use std::fs;
105    use std::path::Path;
106    use std::process::Command;
107
108    use super::*;
109    use crate::git_workspace::WorkspaceState;
110
111    fn git(cwd: &Path, args: &[&str]) -> String {
112        let output = Command::new("git")
113            .args(args)
114            .current_dir(cwd)
115            .output()
116            .unwrap();
117        assert!(
118            output.status.success(),
119            "git {} failed: {}",
120            args.join(" "),
121            String::from_utf8_lossy(&output.stderr)
122        );
123        String::from_utf8(output.stdout).unwrap().trim().to_owned()
124    }
125
126    fn repo() -> tempfile::TempDir {
127        let tmp = tempfile::tempdir().unwrap();
128        git(tmp.path(), &["init", "-q"]);
129        git(tmp.path(), &["config", "user.name", "Atman Test"]);
130        git(
131            tmp.path(),
132            &["config", "user.email", "atman@example.invalid"],
133        );
134        git(tmp.path(), &["config", "commit.gpgsign", "false"]);
135        fs::write(tmp.path().join("README.md"), "committed\n").unwrap();
136        git(tmp.path(), &["add", "README.md"]);
137        git(tmp.path(), &["commit", "-q", "-m", "initial"]);
138        tmp
139    }
140
141    #[test]
142    fn none_policy_does_not_require_a_repository() {
143        let service = FlowWorkspaceService::new("missing", None, "generation").unwrap();
144        assert_eq!(
145            service
146                .allocate(WorkspacePolicy::None, "session", "flow", None)
147                .unwrap(),
148            None
149        );
150    }
151
152    #[test]
153    fn auto_workspace_persists_trusted_lease_and_releases_cleanly() {
154        let repo = repo();
155        let service = FlowWorkspaceService::new(repo.path(), None, "generation-one").unwrap();
156        let binding = service
157            .allocate(WorkspacePolicy::Auto, "session", "0195-flow", None)
158            .unwrap()
159            .unwrap();
160        let manager = WorkspaceManager::at(repo.path(), None).unwrap();
161        let record = manager.get(&binding.workspace_id).unwrap();
162        assert_eq!(record.owner_session.as_deref(), Some("session"));
163        assert_eq!(record.owner_flow.as_deref(), Some("0195-flow"));
164        assert_eq!(record.lifecycle_state(), WorkspaceState::Active);
165        assert_eq!(
166            record
167                .lease
168                .as_ref()
169                .map(|lease| lease.daemon_generation.as_str()),
170            Some("generation-one")
171        );
172
173        let outcome = service.finalize(&binding, "session", "0195-flow").unwrap();
174        assert!(matches!(outcome, WorkspaceFinalizeOutcome::Released(_)));
175        assert!(!binding.path.exists());
176        assert!(matches!(
177            service.finalize(&binding, "session", "0195-flow").unwrap(),
178            WorkspaceFinalizeOutcome::AlreadyReleased(_)
179        ));
180    }
181
182    #[test]
183    fn nested_workspace_starts_from_parent_execution_head() {
184        let repo = repo();
185        let original_cwd = std::env::current_dir().unwrap();
186        let main_head = git(repo.path(), &["rev-parse", "HEAD"]);
187        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
188        let parent = service
189            .allocate(WorkspacePolicy::Retain, "session", "parent", None)
190            .unwrap()
191            .unwrap();
192
193        fs::write(parent.path.join("parent-marker.txt"), "from parent\n").unwrap();
194        git(&parent.path, &["add", "parent-marker.txt"]);
195        git(&parent.path, &["commit", "-q", "-m", "parent marker"]);
196        let parent_head = git(&parent.path, &["rev-parse", "HEAD"]);
197
198        let child = service
199            .allocate(
200                WorkspacePolicy::Retain,
201                "session",
202                "child",
203                Some(&parent.path),
204            )
205            .unwrap()
206            .unwrap();
207        let child_head = git(&child.path, &["rev-parse", "HEAD"]);
208        let manager = WorkspaceManager::at(repo.path(), None).unwrap();
209        let child_record = manager.get(&child.workspace_id).unwrap();
210
211        assert_eq!(child_head, parent_head);
212        assert_eq!(
213            child_record.allocation_base.as_deref(),
214            Some(parent_head.as_str())
215        );
216        assert_eq!(child.repository_root, parent.repository_root);
217        assert_eq!(child.repository_root, manager.repository_root());
218        assert_eq!(
219            fs::read_to_string(child.path.join("parent-marker.txt")).unwrap(),
220            "from parent\n"
221        );
222        assert_eq!(git(repo.path(), &["rev-parse", "HEAD"]), main_head);
223        assert_eq!(std::env::current_dir().unwrap(), original_cwd);
224    }
225
226    #[test]
227    fn dirty_and_retained_workspaces_are_preserved() {
228        let repo = repo();
229        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
230        let dirty = service
231            .allocate(WorkspacePolicy::Auto, "session", "dirty", None)
232            .unwrap()
233            .unwrap();
234        fs::write(dirty.path.join("dirty.txt"), "inspect me\n").unwrap();
235        assert!(matches!(
236            service.finalize(&dirty, "session", "dirty").unwrap(),
237            WorkspaceFinalizeOutcome::Dirty(_)
238        ));
239        assert!(dirty.path.exists());
240
241        let retained = service
242            .allocate(WorkspacePolicy::Retain, "session", "retained", None)
243            .unwrap()
244            .unwrap();
245        assert!(matches!(
246            service.finalize(&retained, "session", "retained").unwrap(),
247            WorkspaceFinalizeOutcome::Retained(_)
248        ));
249        assert!(retained.path.exists());
250    }
251
252    #[test]
253    fn owner_mismatch_and_unconfigured_bare_repository_are_rejected() {
254        let repo = repo();
255        let service = FlowWorkspaceService::new(repo.path(), None, "generation").unwrap();
256        let binding = service
257            .allocate(WorkspacePolicy::Auto, "session", "owned", None)
258            .unwrap()
259            .unwrap();
260        assert!(service.finalize(&binding, "other", "owned").is_err());
261        assert!(binding.path.exists());
262
263        let bare_parent = tempfile::tempdir().unwrap();
264        let bare = bare_parent.path().join("repo.git");
265        git(
266            repo.path(),
267            &["clone", "-q", "--bare", ".", bare.to_str().unwrap()],
268        );
269        let bare_service = FlowWorkspaceService::new(&bare, None, "generation").unwrap();
270        assert!(
271            bare_service
272                .allocate(WorkspacePolicy::Auto, "session", "bare", None)
273                .is_err()
274        );
275    }
276}