Skip to main content

chainlink/
locks.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// A single issue lock entry in locks.json.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Lock {
10    pub agent_id: String,
11    #[serde(default)]
12    pub branch: Option<String>,
13    pub claimed_at: DateTime<Utc>,
14    pub signed_by: String,
15}
16
17/// Settings embedded in locks.json.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct LockSettings {
20    #[serde(default = "default_stale_timeout")]
21    pub stale_lock_timeout_minutes: u64,
22}
23
24fn default_stale_timeout() -> u64 {
25    60
26}
27
28impl Default for LockSettings {
29    fn default() -> Self {
30        Self {
31            stale_lock_timeout_minutes: default_stale_timeout(),
32        }
33    }
34}
35
36/// The top-level locks.json structure on the coordination branch.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct LocksFile {
39    pub version: u32,
40    /// Map from issue ID (as string) to Lock.
41    pub locks: HashMap<String, Lock>,
42    #[serde(default)]
43    pub settings: LockSettings,
44}
45
46impl LocksFile {
47    /// Load and parse a locks.json file.
48    pub fn load(path: &Path) -> Result<Self> {
49        let content = std::fs::read_to_string(path)
50            .with_context(|| format!("Failed to read {}", path.display()))?;
51        serde_json::from_str(&content)
52            .with_context(|| format!("Failed to parse {}", path.display()))
53    }
54
55    /// Check if a specific issue is locked.
56    pub fn is_locked(&self, issue_id: i64) -> bool {
57        self.locks.contains_key(&issue_id.to_string())
58    }
59
60    /// Get the lock for a specific issue.
61    pub fn get_lock(&self, issue_id: i64) -> Option<&Lock> {
62        self.locks.get(&issue_id.to_string())
63    }
64
65    /// Check if an issue is locked by a specific agent.
66    pub fn is_locked_by(&self, issue_id: i64, agent_id: &str) -> bool {
67        self.locks
68            .get(&issue_id.to_string())
69            .map(|l| l.agent_id == agent_id)
70            .unwrap_or(false)
71    }
72
73    /// List all issue IDs locked by a specific agent.
74    pub fn agent_locks(&self, agent_id: &str) -> Vec<i64> {
75        self.locks
76            .iter()
77            .filter(|(_, lock)| lock.agent_id == agent_id)
78            .filter_map(|(id, _)| id.parse().ok())
79            .collect()
80    }
81
82    /// Create an empty locks file.
83    pub fn empty() -> Self {
84        LocksFile {
85            version: 1,
86            locks: HashMap::new(),
87            settings: LockSettings::default(),
88        }
89    }
90
91    /// Save the locks file to disk.
92    pub fn save(&self, path: &Path) -> Result<()> {
93        let json = serde_json::to_string_pretty(self)?;
94        std::fs::write(path, json).with_context(|| format!("Failed to write {}", path.display()))
95    }
96}
97
98/// Heartbeat file for an agent (lives at heartbeats/{agent_id}.json).
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct Heartbeat {
101    pub agent_id: String,
102    pub last_heartbeat: DateTime<Utc>,
103    pub active_issue_id: Option<i64>,
104    pub machine_id: String,
105}
106
107/// Trust keyring — list of trusted GPG fingerprints.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct Keyring {
110    pub trusted_fingerprints: Vec<String>,
111}
112
113impl Keyring {
114    /// Load and parse a keyring.json file.
115    pub fn load(path: &Path) -> Result<Self> {
116        let content = std::fs::read_to_string(path)
117            .with_context(|| format!("Failed to read {}", path.display()))?;
118        serde_json::from_str(&content)
119            .with_context(|| format!("Failed to parse {}", path.display()))
120    }
121
122    /// Check if a fingerprint is trusted.
123    pub fn is_trusted(&self, fingerprint: &str) -> bool {
124        self.trusted_fingerprints.iter().any(|f| f == fingerprint)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use tempfile::tempdir;
132
133    fn sample_lock() -> Lock {
134        Lock {
135            agent_id: "worker-1".to_string(),
136            branch: Some("feature/auth".to_string()),
137            claimed_at: Utc::now(),
138            signed_by: "ABCD1234".to_string(),
139        }
140    }
141
142    fn sample_locks_file() -> LocksFile {
143        let mut locks = HashMap::new();
144        locks.insert("5".to_string(), sample_lock());
145        locks.insert(
146            "8".to_string(),
147            Lock {
148                agent_id: "worker-2".to_string(),
149                branch: Some("fix/api-timeout".to_string()),
150                claimed_at: Utc::now(),
151                signed_by: "EFGH5678".to_string(),
152            },
153        );
154        LocksFile {
155            version: 1,
156            locks,
157            settings: LockSettings::default(),
158        }
159    }
160
161    #[test]
162    fn test_empty_locks() {
163        let locks = LocksFile::empty();
164        assert_eq!(locks.version, 1);
165        assert!(locks.locks.is_empty());
166        assert_eq!(locks.settings.stale_lock_timeout_minutes, 60);
167    }
168
169    #[test]
170    fn test_is_locked() {
171        let locks = sample_locks_file();
172        assert!(locks.is_locked(5));
173        assert!(locks.is_locked(8));
174        assert!(!locks.is_locked(1));
175    }
176
177    #[test]
178    fn test_get_lock() {
179        let locks = sample_locks_file();
180        let lock = locks.get_lock(5).unwrap();
181        assert_eq!(lock.agent_id, "worker-1");
182        assert!(locks.get_lock(99).is_none());
183    }
184
185    #[test]
186    fn test_is_locked_by() {
187        let locks = sample_locks_file();
188        assert!(locks.is_locked_by(5, "worker-1"));
189        assert!(!locks.is_locked_by(5, "worker-2"));
190        assert!(locks.is_locked_by(8, "worker-2"));
191        assert!(!locks.is_locked_by(99, "worker-1"));
192    }
193
194    #[test]
195    fn test_agent_locks() {
196        let locks = sample_locks_file();
197        let mut w1 = locks.agent_locks("worker-1");
198        w1.sort();
199        assert_eq!(w1, vec![5]);
200        let mut w2 = locks.agent_locks("worker-2");
201        w2.sort();
202        assert_eq!(w2, vec![8]);
203        assert!(locks.agent_locks("nobody").is_empty());
204    }
205
206    #[test]
207    fn test_load_and_save_roundtrip() {
208        let dir = tempdir().unwrap();
209        let path = dir.path().join("locks.json");
210        let locks = sample_locks_file();
211        let json = serde_json::to_string_pretty(&locks).unwrap();
212        std::fs::write(&path, json).unwrap();
213
214        let loaded = LocksFile::load(&path).unwrap();
215        assert_eq!(loaded.version, locks.version);
216        assert_eq!(loaded.locks.len(), locks.locks.len());
217        assert_eq!(
218            loaded.settings.stale_lock_timeout_minutes,
219            locks.settings.stale_lock_timeout_minutes
220        );
221    }
222
223    #[test]
224    fn test_default_settings() {
225        let settings = LockSettings::default();
226        assert_eq!(settings.stale_lock_timeout_minutes, 60);
227    }
228
229    #[test]
230    fn test_keyring_is_trusted() {
231        let keyring = Keyring {
232            trusted_fingerprints: vec!["AAAA1111".to_string(), "BBBB2222".to_string()],
233        };
234        assert!(keyring.is_trusted("AAAA1111"));
235        assert!(keyring.is_trusted("BBBB2222"));
236        assert!(!keyring.is_trusted("CCCC3333"));
237    }
238
239    #[test]
240    fn test_heartbeat_json_roundtrip() {
241        let hb = Heartbeat {
242            agent_id: "worker-1".to_string(),
243            last_heartbeat: Utc::now(),
244            active_issue_id: Some(42),
245            machine_id: "my-host".to_string(),
246        };
247        let json = serde_json::to_string(&hb).unwrap();
248        let parsed: Heartbeat = serde_json::from_str(&json).unwrap();
249        assert_eq!(parsed.agent_id, hb.agent_id);
250        assert_eq!(parsed.active_issue_id, Some(42));
251    }
252}