Skip to main content

sim_lib_agent/atelier/
mission_lease.rs

1//! Workspace leases and conflict checks for Atelier missions.
2
3use super::mission::{AgentMission, key};
4use sim_kernel::{Expr, Symbol};
5
6/// Workspace lease target kind.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub enum WorkspaceLeaseKind {
9    /// A single file path within a repo.
10    File,
11    /// A directory subtree within a repo.
12    Directory,
13    /// A named crate within a repo.
14    Crate,
15    /// An IDE object identified by symbol, independent of any repo.
16    IdeObject,
17}
18
19/// Lease mode. Write leases conflict with overlapping read or write leases.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum WorkspaceLeaseMode {
22    /// Read access that tolerates other concurrent readers.
23    SharedRead,
24    /// Write access that excludes any overlapping read or write lease.
25    ExclusiveWrite,
26}
27
28/// Declared claim on a file, directory, crate, or IDE object.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct WorkspaceLease {
31    /// What kind of target the lease claims.
32    pub kind: WorkspaceLeaseKind,
33    /// Owning repo, or `None` for repo-independent targets such as IDE objects.
34    pub repo: Option<String>,
35    /// Normalized target path, crate name, or object id.
36    pub target: String,
37    /// Whether the claim is a shared read or an exclusive write.
38    pub mode: WorkspaceLeaseMode,
39}
40
41impl WorkspaceLease {
42    /// Builds an exclusive-write lease on a file in a repo.
43    pub fn file(repo: impl Into<String>, path: impl Into<String>) -> Self {
44        Self::repo_target(WorkspaceLeaseKind::File, repo, path)
45    }
46
47    /// Builds an exclusive-write lease on a directory subtree in a repo.
48    pub fn directory(repo: impl Into<String>, path: impl Into<String>) -> Self {
49        Self::repo_target(WorkspaceLeaseKind::Directory, repo, path)
50    }
51
52    /// Builds an exclusive-write lease on a named crate in a repo.
53    pub fn crate_name(repo: impl Into<String>, crate_name: impl Into<String>) -> Self {
54        Self::repo_target(WorkspaceLeaseKind::Crate, repo, crate_name)
55    }
56
57    /// Builds an exclusive-write lease on an IDE object by id.
58    pub fn ide_object(object_id: Symbol) -> Self {
59        Self {
60            kind: WorkspaceLeaseKind::IdeObject,
61            repo: None,
62            target: object_id.to_string(),
63            mode: WorkspaceLeaseMode::ExclusiveWrite,
64        }
65    }
66
67    /// Returns the lease downgraded to shared-read mode.
68    pub fn for_read(mut self) -> Self {
69        self.mode = WorkspaceLeaseMode::SharedRead;
70        self
71    }
72
73    /// Returns the lease upgraded to exclusive-write mode.
74    pub fn for_write(mut self) -> Self {
75        self.mode = WorkspaceLeaseMode::ExclusiveWrite;
76        self
77    }
78
79    pub(crate) fn as_expr(&self) -> Expr {
80        let mut entries = vec![
81            key(
82                "kind",
83                Expr::Symbol(Symbol::new(lease_kind_label(&self.kind))),
84            ),
85            key("target", Expr::String(self.target.clone())),
86            key(
87                "mode",
88                Expr::Symbol(Symbol::new(lease_mode_label(&self.mode))),
89            ),
90        ];
91        if let Some(repo) = &self.repo {
92            entries.push(key("repo", Expr::String(repo.clone())));
93        }
94        Expr::Map(entries)
95    }
96
97    fn repo_target(
98        kind: WorkspaceLeaseKind,
99        repo: impl Into<String>,
100        target: impl Into<String>,
101    ) -> Self {
102        Self {
103            kind,
104            repo: Some(repo.into()),
105            target: normalize_path(&target.into()),
106            mode: WorkspaceLeaseMode::ExclusiveWrite,
107        }
108    }
109
110    fn conflicts_with(&self, other: &Self) -> bool {
111        if self.mode == WorkspaceLeaseMode::SharedRead
112            && other.mode == WorkspaceLeaseMode::SharedRead
113        {
114            return false;
115        }
116        leases_overlap(self, other)
117    }
118}
119
120/// Conflict between two mission leases.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct WorkspaceLeaseConflict {
123    /// Id of the mission holding the left lease.
124    pub left_mission: Symbol,
125    /// The conflicting lease from the left mission.
126    pub left: WorkspaceLease,
127    /// Id of the mission holding the right lease.
128    pub right_mission: Symbol,
129    /// The conflicting lease from the right mission.
130    pub right: WorkspaceLease,
131}
132
133/// Finds incompatible leases across mission pairs.
134pub fn detect_workspace_lease_conflicts(missions: &[AgentMission]) -> Vec<WorkspaceLeaseConflict> {
135    let mut conflicts = Vec::new();
136    for (left_index, left_mission) in missions.iter().enumerate() {
137        for right_mission in missions.iter().skip(left_index + 1) {
138            for left in left_mission.leases() {
139                for right in right_mission.leases() {
140                    if left.conflicts_with(right) {
141                        conflicts.push(WorkspaceLeaseConflict {
142                            left_mission: left_mission.id().clone(),
143                            left: left.clone(),
144                            right_mission: right_mission.id().clone(),
145                            right: right.clone(),
146                        });
147                    }
148                }
149            }
150        }
151    }
152    conflicts
153}
154
155pub(crate) fn leases_expr(leases: &[WorkspaceLease]) -> Expr {
156    Expr::List(leases.iter().map(WorkspaceLease::as_expr).collect())
157}
158
159fn leases_overlap(left: &WorkspaceLease, right: &WorkspaceLease) -> bool {
160    if left.kind == WorkspaceLeaseKind::IdeObject || right.kind == WorkspaceLeaseKind::IdeObject {
161        return left.kind == right.kind && left.target == right.target;
162    }
163    if left.repo != right.repo {
164        return false;
165    }
166    match (&left.kind, &right.kind) {
167        (WorkspaceLeaseKind::Crate, WorkspaceLeaseKind::Crate) => left.target == right.target,
168        (WorkspaceLeaseKind::File, WorkspaceLeaseKind::File) => left.target == right.target,
169        (WorkspaceLeaseKind::Directory, WorkspaceLeaseKind::Directory) => {
170            path_contains(&left.target, &right.target) || path_contains(&right.target, &left.target)
171        }
172        (WorkspaceLeaseKind::Directory, WorkspaceLeaseKind::File) => {
173            path_contains(&left.target, &right.target)
174        }
175        (WorkspaceLeaseKind::File, WorkspaceLeaseKind::Directory) => {
176            path_contains(&right.target, &left.target)
177        }
178        _ => false,
179    }
180}
181
182fn path_contains(directory: &str, path: &str) -> bool {
183    directory == "."
184        || directory == path
185        || path
186            .strip_prefix(directory)
187            .is_some_and(|rest| rest.starts_with('/'))
188}
189
190fn normalize_path(path: &str) -> String {
191    let normalized = path.trim_matches('/').replace('\\', "/");
192    if normalized.is_empty() {
193        ".".to_owned()
194    } else {
195        normalized
196    }
197}
198
199fn lease_kind_label(kind: &WorkspaceLeaseKind) -> &'static str {
200    match kind {
201        WorkspaceLeaseKind::File => "file",
202        WorkspaceLeaseKind::Directory => "directory",
203        WorkspaceLeaseKind::Crate => "crate",
204        WorkspaceLeaseKind::IdeObject => "ide-object",
205    }
206}
207
208fn lease_mode_label(mode: &WorkspaceLeaseMode) -> &'static str {
209    match mode {
210        WorkspaceLeaseMode::SharedRead => "shared-read",
211        WorkspaceLeaseMode::ExclusiveWrite => "exclusive-write",
212    }
213}