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}