Skip to main content

plan_issue/
lifecycle_lock.rs

1//! Local lifecycle mutation lock for provider issue comments.
2//!
3//! The lock is intentionally local and fail-fast: it prevents two agent
4//! processes on the same machine from mutating the same lifecycle stream at
5//! once, while leaving provider-level cross-machine coordination out of scope.
6
7use std::fs::{self, OpenOptions};
8use std::io::{self, Write};
9use std::path::PathBuf;
10
11use crate::commands::record::RecordProfile;
12use crate::provider::Repo;
13use crate::{CommandError, state};
14
15const LOCK_DIR: &str = "lifecycle-post";
16const LOCK_BUSY_CODE: &str = "plan-issue-lifecycle-lock-busy";
17
18#[derive(Debug)]
19pub struct LifecycleMutationLock {
20    path: PathBuf,
21}
22
23impl Drop for LifecycleMutationLock {
24    fn drop(&mut self) {
25        match fs::remove_file(&self.path) {
26            Ok(()) => {}
27            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
28            Err(_) => {}
29        }
30    }
31}
32
33/// Acquire the local lock for one provider issue lifecycle stream.
34pub fn acquire(
35    repo: &Repo,
36    issue: u64,
37    profile: RecordProfile,
38) -> Result<LifecycleMutationLock, CommandError> {
39    acquire_for_identity(
40        repo.provider.as_str(),
41        repo.host.as_deref(),
42        &repo.slug,
43        issue,
44        profile,
45    )
46}
47
48/// Acquire the local lock from a provider/repo identity.
49///
50/// This is public so integration tests and future provider-neutral callers do
51/// not need access to the internal provider-routing module.
52pub fn acquire_for_identity(
53    provider: &str,
54    host: Option<&str>,
55    repo_slug: &str,
56    issue: u64,
57    profile: RecordProfile,
58) -> Result<LifecycleMutationLock, CommandError> {
59    let dir = state::state_dir().join("locks").join(LOCK_DIR);
60    fs::create_dir_all(&dir).map_err(|err| {
61        CommandError::runtime(
62            "plan-issue-lifecycle-lock-dir-failed",
63            format!(
64                "failed to create lifecycle lock directory {}: {err}",
65                dir.display()
66            ),
67        )
68    })?;
69
70    let key = key_for(provider, host, repo_slug, issue, profile);
71    let path = dir.join(format!("{key}.lock"));
72    let payload = payload_for(provider, host, repo_slug, issue, profile);
73    match OpenOptions::new().write(true).create_new(true).open(&path) {
74        Ok(mut file) => {
75            if let Err(err) = file.write_all(payload.as_bytes()) {
76                let _ = fs::remove_file(&path);
77                return Err(CommandError::runtime(
78                    "plan-issue-lifecycle-lock-write-failed",
79                    format!("failed to write lifecycle lock {}: {err}", path.display()),
80                ));
81            }
82            if let Err(err) = file.flush() {
83                let _ = fs::remove_file(&path);
84                return Err(CommandError::runtime(
85                    "plan-issue-lifecycle-lock-write-failed",
86                    format!("failed to flush lifecycle lock {}: {err}", path.display()),
87                ));
88            }
89            Ok(LifecycleMutationLock { path })
90        }
91        Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Err(CommandError::runtime(
92            LOCK_BUSY_CODE,
93            format!(
94                "another plan-issue lifecycle mutation is already in progress for provider={} repo={} issue={} profile={}; retry after it finishes or remove stale lock {} if the owning process exited",
95                provider,
96                repo_slug,
97                issue,
98                profile.as_str(),
99                path.display(),
100            ),
101        )),
102        Err(err) => Err(CommandError::runtime(
103            "plan-issue-lifecycle-lock-acquire-failed",
104            format!("failed to acquire lifecycle lock {}: {err}", path.display()),
105        )),
106    }
107}
108
109fn key_for(
110    provider: &str,
111    host: Option<&str>,
112    repo_slug: &str,
113    issue: u64,
114    profile: RecordProfile,
115) -> String {
116    let host = host.unwrap_or("default");
117    sanitize_key(&format!(
118        "{}__{}__{}__issue-{}__{}",
119        provider,
120        host,
121        repo_slug,
122        issue,
123        profile.as_str()
124    ))
125}
126
127fn payload_for(
128    provider: &str,
129    host: Option<&str>,
130    repo_slug: &str,
131    issue: u64,
132    profile: RecordProfile,
133) -> String {
134    format!(
135        "provider={}\nhost={}\nrepo={}\nissue={}\nprofile={}\n",
136        provider,
137        host.unwrap_or("default"),
138        repo_slug,
139        issue,
140        profile.as_str(),
141    )
142}
143
144fn sanitize_key(raw: &str) -> String {
145    raw.chars()
146        .map(|ch| {
147            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
148                ch
149            } else {
150                '_'
151            }
152        })
153        .collect()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::provider::{Provider, Repo};
160    use nils_test_support::{EnvGuard, GlobalStateLock};
161    use tempfile::TempDir;
162
163    fn repo() -> Repo {
164        Repo {
165            provider: Provider::GitHub,
166            slug: "owner/repo".to_string(),
167            host: Some("github.com".to_string()),
168        }
169    }
170
171    fn isolate_state(lock: &GlobalStateLock) -> (TempDir, EnvGuard) {
172        crate::state::set_state_dir_override(None);
173        let tmp = TempDir::new().expect("tmp");
174        let path = tmp.path().to_string_lossy().to_string();
175        let guard = EnvGuard::set(lock, "PLAN_ISSUE_HOME", &path);
176        (tmp, guard)
177    }
178
179    #[test]
180    fn lifecycle_lock_is_issue_scoped_and_released_on_drop() {
181        let lock = GlobalStateLock::new();
182        let (_state, _env) = isolate_state(&lock);
183        let repo = repo();
184
185        let first = acquire(&repo, 42, RecordProfile::Tracking).expect("first lock");
186        let busy = acquire(&repo, 42, RecordProfile::Tracking).expect_err("second lock busy");
187        assert_eq!(busy.code, LOCK_BUSY_CODE);
188
189        acquire(&repo, 43, RecordProfile::Tracking).expect("different issue");
190        acquire(&repo, 42, RecordProfile::Dispatch).expect("different profile");
191
192        drop(first);
193        acquire(&repo, 42, RecordProfile::Tracking).expect("lock released");
194    }
195
196    #[test]
197    fn lifecycle_lock_key_is_path_safe() {
198        let repo = Repo {
199            provider: Provider::GitLab,
200            slug: "group/sub/project".to_string(),
201            host: Some("gitlab.example.com".to_string()),
202        };
203        let key = key_for(
204            repo.provider.as_str(),
205            repo.host.as_deref(),
206            &repo.slug,
207            7,
208            RecordProfile::Dispatch,
209        );
210        assert!(!key.contains('/'));
211        assert!(!key.contains('\\'));
212        assert!(key.contains("gitlab"));
213        assert!(key.contains("issue-7"));
214    }
215}