1pub(crate) use gate4agent_node_core::managed_worktree::{ManagedWorktreeLeaseRecord, ManagedWorktreeSessionHolder};
2use crate::git_worktree::{paths_equal, validate_managed_allocation_root};
3use crate::protocol::{
4 GitObjectId, ManagedWorktreeCleanupFailure, ManagedWorktreeGitScope,
5 ManagedWorktreeLeaseId, ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState,
6 ManagedWorktreeRetention, NodeIncarnationId, SessionRecordId, WorktreeProfileId,
7 WorktreeProfileRevision, WorkspaceId, MAX_MANAGED_WORKTREE_LEASES,
8};
9use gate4agent_node_wire::random_nonce;
10use gate4agent_types::{AgentInstanceId, SessionGeneration};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15const MAX_MANAGED_WORKTREE_TOMBSTONES: usize = MAX_MANAGED_WORKTREE_LEASES;
16const MAX_BRANCH_PREFIX_BYTES: usize = 256;
17const MAX_BASE_REVISION_BYTES: usize = 1_024;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct ManagedWorktreeProfile {
21 profile_id: WorktreeProfileId,
22 revision: WorktreeProfileRevision,
23 allocation_root: String,
24 branch_prefix: String,
25 base: String,
26 retention: ManagedWorktreeRetention,
27}
28
29impl ManagedWorktreeProfile {
30 pub fn new(
31 profile_id: WorktreeProfileId,
32 revision: WorktreeProfileRevision,
33 allocation_root: impl AsRef<Path>,
34 branch_prefix: impl Into<String>,
35 base: impl Into<String>,
36 retention: ManagedWorktreeRetention,
37 ) -> Result<Self, String> {
38 let allocation_root = allocation_root.as_ref();
39 if !allocation_root.is_absolute() {
40 return Err("managed worktree allocation root must be absolute".to_owned());
41 }
42 let canonical = std::fs::canonicalize(allocation_root)
43 .map_err(|error| format!("managed worktree allocation root is unavailable: {error}"))?;
44 if !canonical.is_dir() {
45 return Err("managed worktree allocation root must be a directory".to_owned());
46 }
47 let allocation_root = canonical.into_os_string().into_string().map_err(|path| {
48 format!(
49 "managed worktree allocation root is not valid Unicode: {}",
50 path.to_string_lossy(),
51 )
52 })?;
53 let allocation_root = crate::platform::normalize_canonical_root(allocation_root);
54 let branch_prefix = branch_prefix.into();
55 if branch_prefix.is_empty()
56 || branch_prefix.len() > MAX_BRANCH_PREFIX_BYTES
57 || branch_prefix.chars().any(char::is_control)
58 || branch_prefix.ends_with('/')
59 {
60 return Err("managed worktree branch prefix is invalid".to_owned());
61 }
62 let base = base.into();
63 if base.is_empty()
64 || base.len() > MAX_BASE_REVISION_BYTES
65 || base.chars().any(char::is_control)
66 {
67 return Err("managed worktree base revision is invalid".to_owned());
68 }
69 Ok(Self {
70 profile_id,
71 revision,
72 allocation_root,
73 branch_prefix,
74 base,
75 retention,
76 })
77 }
78
79 pub fn profile_id(&self) -> &WorktreeProfileId {
80 &self.profile_id
81 }
82
83 pub fn revision(&self) -> &WorktreeProfileRevision {
84 &self.revision
85 }
86
87 pub fn allocation_root(&self) -> &str {
88 &self.allocation_root
89 }
90
91 pub fn branch_prefix(&self) -> &str {
92 &self.branch_prefix
93 }
94
95 pub fn base(&self) -> &str {
96 &self.base
97 }
98
99 pub fn retention(&self) -> ManagedWorktreeRetention {
100 self.retention
101 }
102
103 pub(crate) fn validate_for_workspace(&self, source_root: &str) -> Result<(), String> {
104 validate_managed_allocation_root(source_root, &self.allocation_root)
105 .map_err(|error| error.message)
106 }
107
108 pub(crate) fn validate_target_authority(
109 &self,
110 lease_id: &ManagedWorktreeLeaseId,
111 target_root: &str,
112 ) -> Result<(), String> {
113 let current_root = std::fs::canonicalize(&self.allocation_root)
114 .map_err(|error| format!("managed worktree allocation root is unavailable: {error}"))?;
115 let current_root = crate::platform::normalize_canonical_root(
116 current_root.into_os_string().into_string()
117 .map_err(|_| "managed worktree allocation root is not valid Unicode".to_owned())?,
118 );
119 if !paths_equal(¤t_root, &self.allocation_root) {
120 return Err("managed worktree allocation root identity changed".to_owned());
121 }
122 let target = Path::new(target_root);
123 let expected = PathBuf::from(&self.allocation_root).join(lease_id.as_str());
124 if target != expected
125 || target.file_name().and_then(|name| name.to_str()) != Some(lease_id.as_str())
126 || target.components().any(|component| matches!(
127 component,
128 std::path::Component::CurDir | std::path::Component::ParentDir,
129 ))
130 {
131 return Err("managed worktree target is not the exact allocated lease child".to_owned());
132 }
133 let parent = target.parent()
134 .ok_or_else(|| "managed worktree target has no parent".to_owned())?;
135 let canonical_parent = std::fs::canonicalize(parent)
136 .map_err(|error| format!("managed worktree target parent is unavailable: {error}"))?;
137 let canonical_parent = crate::platform::normalize_canonical_root(
138 canonical_parent.into_os_string().into_string()
139 .map_err(|_| "managed worktree target parent is not valid Unicode".to_owned())?,
140 );
141 if !paths_equal(&canonical_parent, &self.allocation_root)
142 {
143 return Err("managed worktree target is not an exact child of its allocation root".to_owned());
144 }
145 Ok(())
146 }
147}
148
149#[derive(Clone, Debug, Default, Eq, PartialEq)]
150pub(crate) struct ManagedWorktreeRegistry {
151 leases: BTreeMap<ManagedWorktreeLeaseId, ManagedWorktreeLeaseRecord>,
152 tombstones: BTreeMap<ManagedWorktreeLeaseId, ManagedWorktreeLeaseRecord>,
153}
154
155impl ManagedWorktreeRegistry {
156 pub(crate) fn from_records(
157 records: Vec<ManagedWorktreeLeaseRecord>,
158 tombstones: Vec<ManagedWorktreeLeaseRecord>,
159 ) -> Result<Self, String> {
160 if records.len() > MAX_MANAGED_WORKTREE_LEASES
161 || tombstones.len() > MAX_MANAGED_WORKTREE_TOMBSTONES
162 {
163 return Err("managed worktree durable registry exceeds its bounded capacity".to_owned());
164 }
165 let mut registry = Self::default();
166 let mut workspace_ids = BTreeSet::new();
167 let mut target_roots = BTreeSet::new();
168 let mut branches = BTreeSet::new();
169 for record in records {
170 if record.state == ManagedWorktreeLeaseState::Removed
171 || !workspace_ids.insert(record.workspace_id.clone())
172 || !target_roots.insert(crate::platform::root_identity(&record.target_root))
173 || !branches.insert(record.branch.clone())
174 || registry.leases.insert(record.lease_id.clone(), record).is_some()
175 {
176 return Err("managed worktree durable registry is inconsistent".to_owned());
177 }
178 }
179 for record in tombstones {
180 if record.state != ManagedWorktreeLeaseState::Removed
181 || registry.leases.contains_key(&record.lease_id)
182 || registry.tombstones.insert(record.lease_id.clone(), record).is_some()
183 {
184 return Err("managed worktree tombstone registry is inconsistent".to_owned());
185 }
186 }
187 Ok(registry)
188 }
189
190 pub(crate) fn records(&self) -> Vec<ManagedWorktreeLeaseRecord> {
191 self.leases.values().cloned().collect()
192 }
193
194 pub(crate) fn tombstones(&self) -> Vec<ManagedWorktreeLeaseRecord> {
195 self.tombstones.values().cloned().collect()
196 }
197
198 pub(crate) fn snapshots(&self) -> Vec<ManagedWorktreeLeaseSnapshot> {
199 self.leases.values().map(ManagedWorktreeLeaseRecord::snapshot).collect()
200 }
201
202 pub(crate) fn get(&self, lease_id: &ManagedWorktreeLeaseId) -> Option<&ManagedWorktreeLeaseRecord> {
203 self.leases.get(lease_id).or_else(|| self.tombstones.get(lease_id))
204 }
205
206 pub(crate) fn get_mut(
207 &mut self,
208 lease_id: &ManagedWorktreeLeaseId,
209 ) -> Option<&mut ManagedWorktreeLeaseRecord> {
210 self.leases.get_mut(lease_id)
211 }
212
213 pub(crate) fn active_records(&self) -> impl Iterator<Item = &ManagedWorktreeLeaseRecord> {
214 self.leases.values()
215 }
216
217 pub(crate) fn git_scope(
218 &self,
219 workspace_id: &WorkspaceId,
220 branch: Option<&str>,
221 ) -> Option<ManagedWorktreeGitScope> {
222 let lease = self.leases.values().find(|lease| {
223 &lease.workspace_id == workspace_id && branch == Some(lease.branch.as_str())
224 })?;
225 if lease.source_workspace_id == *workspace_id || !lease.has_holders() {
226 return None;
227 }
228 Some(ManagedWorktreeGitScope {
229 lease_id: lease.lease_id.clone(),
230 source_workspace_id: lease.source_workspace_id.clone(),
231 branch: lease.branch.clone(),
232 base_commit: GitObjectId::new(lease.base_commit.clone()).ok()?,
233 active_session_count: u16::try_from(lease.session_holders.len()).unwrap_or(u16::MAX),
234 managed_record_count: u16::try_from(lease.record_holders.len()).unwrap_or(u16::MAX),
235 })
236 }
237
238 pub(crate) fn allocate(
239 &mut self,
240 source_workspace_id: WorkspaceId,
241 profile: &ManagedWorktreeProfile,
242 base_commit: String,
243 now: u64,
244 ) -> Result<ManagedWorktreeLeaseRecord, String> {
245 if self.leases.len() >= MAX_MANAGED_WORKTREE_LEASES {
246 return Err("managed worktree lease capacity is full".to_owned());
247 }
248 for _ in 0..8 {
249 let nonce = random_nonce()
250 .map_err(|error| format!("managed worktree identity generation failed: {error}"))?;
251 let suffix = hex_suffix(&nonce[..12]);
252 let lease_id = ManagedWorktreeLeaseId::new(format!("mw-{suffix}"))
253 .map_err(|error| error.to_string())?;
254 if self.leases.contains_key(&lease_id) || self.tombstones.contains_key(&lease_id) {
255 continue;
256 }
257 let workspace_id = WorkspaceId::new(format!("managed-{suffix}"))
258 .map_err(|error| error.to_string())?;
259 let target = PathBuf::from(profile.allocation_root()).join(lease_id.as_str());
260 let target_root = target.into_os_string().into_string().map_err(|path| {
261 format!("managed worktree target is not valid Unicode: {}", path.to_string_lossy())
262 })?;
263 let branch = format!("{}/{}", profile.branch_prefix(), lease_id.as_str());
264 let record = ManagedWorktreeLeaseRecord {
265 lease_id: lease_id.clone(),
266 source_workspace_id,
267 workspace_id,
268 profile_id: profile.profile_id().clone(),
269 profile_revision: profile.revision().clone(),
270 target_root,
271 branch,
272 base_commit,
273 expected_head: None,
274 retention: profile.retention(),
275 state: ManagedWorktreeLeaseState::Allocating,
276 session_holders: Vec::new(),
277 record_holders: Vec::new(),
278 cleanup_failure: None,
279 created_at_unix_ms: now,
280 updated_at_unix_ms: now,
281 };
282 self.leases.insert(lease_id, record.clone());
283 return Ok(record);
284 }
285 Err("could not allocate a unique managed worktree identity".to_owned())
286 }
287
288 pub(crate) fn remove_unmutated(&mut self, lease_id: &ManagedWorktreeLeaseId) {
289 self.leases.remove(lease_id);
290 }
291
292 pub(crate) fn tombstone(
293 &mut self,
294 lease_id: &ManagedWorktreeLeaseId,
295 now: u64,
296 ) -> Option<ManagedWorktreeLeaseRecord> {
297 let mut record = self.leases.remove(lease_id)?;
298 record.state = ManagedWorktreeLeaseState::Removed;
299 record.session_holders.clear();
300 record.record_holders.clear();
301 record.cleanup_failure = None;
302 record.updated_at_unix_ms = now;
303 while self.tombstones.len() >= MAX_MANAGED_WORKTREE_TOMBSTONES {
304 let oldest = self
305 .tombstones
306 .values()
307 .min_by_key(|item| (item.updated_at_unix_ms, item.lease_id.as_str()))
308 .map(|item| item.lease_id.clone());
309 if let Some(oldest) = oldest {
310 self.tombstones.remove(&oldest);
311 } else {
312 break;
313 }
314 }
315 self.tombstones.insert(record.lease_id.clone(), record.clone());
316 Some(record)
317 }
318
319 pub(crate) fn clear_stale_session_holders(&mut self, incarnation: NodeIncarnationId, now: u64) {
320 for lease in self.leases.values_mut() {
321 let previous = lease.session_holders.len();
322 lease.session_holders.retain(|holder| holder.incarnation_id == incarnation);
323 if previous != lease.session_holders.len() {
324 lease.updated_at_unix_ms = now;
325 }
326 }
327 }
328
329 pub(crate) fn bind_session(
330 &mut self,
331 lease_id: &ManagedWorktreeLeaseId,
332 holder: ManagedWorktreeSessionHolder,
333 record_id: Option<SessionRecordId>,
334 now: u64,
335 ) -> Result<ManagedWorktreeLeaseSnapshot, String> {
336 let lease = self.leases.get_mut(lease_id)
337 .ok_or_else(|| "managed worktree lease does not exist".to_owned())?;
338 if !lease.session_holders.contains(&holder) {
339 lease.session_holders.push(holder);
340 }
341 if let Some(record_id) = record_id {
342 if !lease.record_holders.contains(&record_id) {
343 lease.record_holders.push(record_id);
344 }
345 }
346 lease.state = ManagedWorktreeLeaseState::InUse;
347 lease.cleanup_failure = None;
348 lease.updated_at_unix_ms = now;
349 Ok(lease.snapshot())
350 }
351
352 pub(crate) fn release_session(
353 &mut self,
354 lease_id: &ManagedWorktreeLeaseId,
355 incarnation_id: NodeIncarnationId,
356 instance_id: AgentInstanceId,
357 generation: SessionGeneration,
358 now: u64,
359 ) -> Option<ManagedWorktreeLeaseSnapshot> {
360 let lease = self.leases.get_mut(lease_id)?;
361 lease.session_holders.retain(|holder| {
362 holder.incarnation_id != incarnation_id
363 || holder.instance_id != instance_id
364 || holder.generation != generation
365 });
366 if !lease.has_holders() && lease.state == ManagedWorktreeLeaseState::InUse {
367 lease.state = if lease.retention == ManagedWorktreeRetention::Retain {
368 ManagedWorktreeLeaseState::Retained
369 } else {
370 ManagedWorktreeLeaseState::Ready
371 };
372 }
373 lease.updated_at_unix_ms = now;
374 Some(lease.snapshot())
375 }
376
377 pub(crate) fn lease_for_workspace(&self, workspace_id: &WorkspaceId) -> Option<ManagedWorktreeLeaseId> {
378 self.leases.values().find(|lease| &lease.workspace_id == workspace_id)
379 .map(|lease| lease.lease_id.clone())
380 }
381
382 pub(crate) fn reattach_record_holders(&mut self, record_ids: &[(SessionRecordId, WorkspaceId)], now: u64) {
383 for lease in self.leases.values_mut() {
384 let next_holders = record_ids.iter()
385 .filter(|(_, workspace_id)| workspace_id == &lease.workspace_id)
386 .map(|(record_id, _)| record_id.clone())
387 .collect::<Vec<_>>();
388 let previous_holders = lease.record_holders.clone();
389 let previous_state = lease.state;
390 lease.record_holders = next_holders;
391 if !matches!(
392 lease.state,
393 ManagedWorktreeLeaseState::RecoveryRequired
394 | ManagedWorktreeLeaseState::CleanupBlocked
395 | ManagedWorktreeLeaseState::Removed
396 | ManagedWorktreeLeaseState::Allocating
397 ) {
398 if lease.has_holders() {
399 lease.state = ManagedWorktreeLeaseState::InUse;
400 } else if previous_state == ManagedWorktreeLeaseState::InUse {
401 lease.state = if lease.retention == ManagedWorktreeRetention::Retain {
402 ManagedWorktreeLeaseState::Retained
403 } else {
404 ManagedWorktreeLeaseState::Ready
405 };
406 }
407 }
408 if lease.state != previous_state || lease.record_holders != previous_holders {
409 lease.updated_at_unix_ms = now;
410 }
411 }
412 }
413}
414
415pub(crate) fn exact_owned_worktree(
416 lease: &ManagedWorktreeLeaseRecord,
417 path: &str,
418 branch: Option<&str>,
419 _head: &str,
420) -> bool {
421 paths_equal(&lease.target_root, path)
422 && branch == Some(lease.branch.as_str())
423}
424
425pub(crate) fn exact_created_worktree(
426 lease: &ManagedWorktreeLeaseRecord,
427 path: &str,
428 branch: Option<&str>,
429 head: &str,
430) -> bool {
431 exact_owned_worktree(lease, path, branch, head)
432 && lease.expected_head.as_deref().unwrap_or(lease.base_commit.as_str()) == head
433}
434
435fn hex_suffix(bytes: &[u8]) -> String {
436 let mut value = String::with_capacity(bytes.len() * 2);
437 for byte in bytes {
438 use std::fmt::Write as _;
439 let _ = write!(&mut value, "{byte:02x}");
440 }
441 value
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use std::sync::atomic::{AtomicU64, Ordering};
448
449 static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
450
451 fn temporary_root(label: &str) -> PathBuf {
452 let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
453 std::env::temp_dir().join(format!(
454 "gate4agent-managed-worktree-{label}-{}-{sequence}",
455 std::process::id(),
456 ))
457 }
458
459 fn lease(
460 id: &str,
461 workspace: &str,
462 target: &str,
463 branch: &str,
464 ) -> ManagedWorktreeLeaseRecord {
465 ManagedWorktreeLeaseRecord {
466 lease_id: ManagedWorktreeLeaseId::new(id).unwrap(),
467 source_workspace_id: WorkspaceId::new("source").unwrap(),
468 workspace_id: WorkspaceId::new(workspace).unwrap(),
469 profile_id: WorktreeProfileId::new("default").unwrap(),
470 profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
471 target_root: target.to_owned(),
472 branch: branch.to_owned(),
473 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
474 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
475 retention: ManagedWorktreeRetention::RemoveWhenReleased,
476 state: ManagedWorktreeLeaseState::Ready,
477 session_holders: Vec::new(),
478 record_holders: Vec::new(),
479 cleanup_failure: None,
480 created_at_unix_ms: 1,
481 updated_at_unix_ms: 1,
482 }
483 }
484
485 #[test]
486 fn managed_profile_recanonicalizes_root_and_requires_exact_lease_child() {
487 let parent = temporary_root("profile");
488 let source = parent.join("source");
489 let allocation = parent.join("allocation");
490 std::fs::create_dir_all(&source).unwrap();
491 std::fs::create_dir_all(&allocation).unwrap();
492 let profile = ManagedWorktreeProfile::new(
493 WorktreeProfileId::new("default").unwrap(),
494 WorktreeProfileRevision::new("v1").unwrap(),
495 &allocation,
496 "gate4agent",
497 "HEAD",
498 ManagedWorktreeRetention::RemoveWhenReleased,
499 ).unwrap();
500 profile.validate_for_workspace(source.to_str().unwrap()).unwrap();
501 let lease_id = ManagedWorktreeLeaseId::new("mw-abc").unwrap();
502 let exact = PathBuf::from(profile.allocation_root()).join(lease_id.as_str());
503 profile.validate_target_authority(&lease_id, exact.to_str().unwrap()).unwrap();
504 let traversing = PathBuf::from(profile.allocation_root())
505 .join("ignored").join("..").join(lease_id.as_str());
506 assert!(profile.validate_target_authority(&lease_id, traversing.to_str().unwrap()).is_err());
507 std::fs::remove_dir_all(parent).unwrap();
508 }
509
510 #[test]
511 fn registry_rejects_duplicate_workspace_root_and_branch_authority() {
512 let first = lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a");
513 let mut duplicate_workspace = lease("mw-b", "managed-a", "C:/trees/b", "gate4agent/b");
514 assert!(ManagedWorktreeRegistry::from_records(
515 vec![first.clone(), duplicate_workspace.clone()], Vec::new(),
516 ).is_err());
517 duplicate_workspace.workspace_id = WorkspaceId::new("managed-b").unwrap();
518 duplicate_workspace.target_root = first.target_root.clone();
519 assert!(ManagedWorktreeRegistry::from_records(
520 vec![first.clone(), duplicate_workspace.clone()], Vec::new(),
521 ).is_err());
522 duplicate_workspace.target_root = "C:/trees/b".to_owned();
523 duplicate_workspace.branch = first.branch.clone();
524 assert!(ManagedWorktreeRegistry::from_records(
525 vec![first, duplicate_workspace], Vec::new(),
526 ).is_err());
527 }
528
529 #[test]
530 fn exact_current_incarnation_holder_release_transitions_without_head_fence() {
531 let mut registry = ManagedWorktreeRegistry::from_records(
532 vec![lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a")],
533 Vec::new(),
534 ).unwrap();
535 let lease_id = ManagedWorktreeLeaseId::new("mw-a").unwrap();
536 let current = NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]);
537 registry.bind_session(
538 &lease_id,
539 ManagedWorktreeSessionHolder {
540 incarnation_id: current,
541 instance_id: AgentInstanceId(7),
542 generation: SessionGeneration(2),
543 },
544 None,
545 2,
546 ).unwrap();
547 registry.release_session(
548 &lease_id,
549 NodeIncarnationId::from_bytes([2; crate::protocol::NODE_INCARNATION_ID_BYTES]),
550 AgentInstanceId(7),
551 SessionGeneration(2),
552 3,
553 );
554 assert_eq!(registry.get(&lease_id).unwrap().snapshot().active_session_count, 1);
555 registry.release_session(&lease_id, current, AgentInstanceId(7), SessionGeneration(2), 4);
556 assert_eq!(registry.get(&lease_id).unwrap().state, ManagedWorktreeLeaseState::Ready);
557 let record_id = SessionRecordId::new("sr-a").unwrap();
558 registry.reattach_record_holders(
559 &[(record_id, WorkspaceId::new("managed-a").unwrap())],
560 5,
561 );
562 assert_eq!(registry.get(&lease_id).unwrap().state, ManagedWorktreeLeaseState::InUse);
563 assert_eq!(registry.get(&lease_id).unwrap().updated_at_unix_ms, 5);
564 registry.reattach_record_holders(
565 &[(SessionRecordId::new("sr-a").unwrap(), WorkspaceId::new("managed-a").unwrap())],
566 9,
567 );
568 assert_eq!(registry.get(&lease_id).unwrap().updated_at_unix_ms, 5);
569 let record = registry.get(&lease_id).unwrap();
570 assert!(exact_owned_worktree(record, "C:/trees/a", Some("gate4agent/a"), "new-head"));
571 assert!(!exact_created_worktree(record, "C:/trees/a", Some("gate4agent/a"), "new-head"));
572 }
573
574 #[test]
575 fn managed_git_scope_requires_exact_active_workspace_and_branch() {
576 let mut record = lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a");
577 record.session_holders.push(ManagedWorktreeSessionHolder {
578 incarnation_id: NodeIncarnationId::from_bytes([
579 1;
580 crate::protocol::NODE_INCARNATION_ID_BYTES
581 ]),
582 instance_id: AgentInstanceId(7),
583 generation: SessionGeneration(2),
584 });
585 record.record_holders.push(SessionRecordId::new("sr-a").unwrap());
586 let registry = ManagedWorktreeRegistry::from_records(vec![record], Vec::new()).unwrap();
587 let workspace_id = WorkspaceId::new("managed-a").unwrap();
588 let scope = registry
589 .git_scope(&workspace_id, Some("gate4agent/a"))
590 .unwrap();
591 assert_eq!(scope.lease_id.as_str(), "mw-a");
592 assert_eq!(scope.source_workspace_id.as_str(), "source");
593 assert_eq!(scope.branch, "gate4agent/a");
594 assert_eq!(scope.active_session_count, 1);
595 assert_eq!(scope.managed_record_count, 1);
596 assert!(registry.git_scope(&workspace_id, Some("gate4agent/b")).is_none());
597 assert!(registry
598 .git_scope(&WorkspaceId::new("managed-b").unwrap(), Some("gate4agent/a"))
599 .is_none());
600 }
601}