actr_runtime/lifecycle/
compat_lock.rs

1//! compat.lock.toml - 运行时兼容性协商缓存
2//!
3//! 当服务发现无法找到精确匹配但找到兼容匹配时,会创建此文件。
4//! 此文件的存在表示系统处于亚健康状态 (SUB-HEALTHY)。
5//!
6//! ## 功能
7//! - 缓存协商结果,避免重复进行兼容性检查
8//! - 记录系统健康状态,方便运维监控
9//! - 提供快速启动路径,优先尝试已知兼容版本
10//!
11//! ## 存储位置
12//! 此文件存储在操作系统的临时目录中,而非项目目录:
13//! - Linux/macOS: `/tmp/actr/<project_hash>/compat.lock.toml`
14//! - Windows: `%TEMP%\actr\<project_hash>\compat.lock.toml`
15//!
16//! `project_hash` 是根据项目根目录绝对路径计算的唯一哈希值,
17//! 确保同一机器上多个 Actor 实例各有独立的缓存。
18//!
19//! ## 注意
20//! 此文件不应提交到版本控制,因为它反映的是运行时状态。
21
22use chrono::{DateTime, Duration, Utc};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::path::{Path, PathBuf};
26use tokio::fs;
27use tracing::{debug, info, warn};
28
29/// 文件名常量
30const COMPAT_LOCK_FILENAME: &str = "compat.lock.toml";
31
32/// 临时目录下的子目录名称
33const ACTR_TEMP_DIR: &str = "actr";
34
35/// 默认缓存过期时间(24小时)
36const DEFAULT_TTL_HOURS: i64 = 24;
37
38/// 根据项目根目录路径计算唯一哈希值
39///
40/// 返回一个短哈希字符串(16个字符),用于创建临时目录子路径
41fn compute_project_hash(project_root: &Path) -> String {
42    let canonical = project_root
43        .canonicalize()
44        .unwrap_or_else(|_| project_root.to_path_buf());
45    let path_str = canonical.to_string_lossy();
46    let mut hasher = Sha256::new();
47    hasher.update(path_str.as_bytes());
48    let result = hasher.finalize();
49    // 取前8字节(16个十六进制字符)作为哈希
50    hex::encode(&result[..8])
51}
52
53/// 获取 compat.lock.toml 的存储目录
54///
55/// 路径格式:`<temp_dir>/actr/<project_hash>/`
56fn get_compat_lock_dir(project_root: &Path) -> PathBuf {
57    let temp_dir = std::env::temp_dir();
58    let project_hash = compute_project_hash(project_root);
59    temp_dir.join(ACTR_TEMP_DIR).join(project_hash)
60}
61
62/// 兼容性协商记录
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct NegotiationEntry {
65    /// 服务名称(例如 "user-service")
66    pub service_name: String,
67
68    /// 请求的指纹(客户端期望的版本)
69    pub requested_fingerprint: String,
70
71    /// 实际解析的指纹(服务端提供的版本)
72    pub resolved_fingerprint: String,
73
74    /// 兼容性检查结果
75    pub compatibility_check: CompatibilityCheck,
76
77    /// 协商时间
78    pub negotiated_at: DateTime<Utc>,
79
80    /// 过期时间
81    pub expires_at: DateTime<Utc>,
82}
83
84/// 兼容性检查结果
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum CompatibilityCheck {
88    /// 完全兼容(精确匹配)
89    ExactMatch,
90    /// 向后兼容
91    BackwardCompatible,
92    /// 破坏性变更(不应该出现在 lock 文件中)
93    BreakingChanges,
94}
95
96impl std::fmt::Display for CompatibilityCheck {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            CompatibilityCheck::ExactMatch => write!(f, "exact_match"),
100            CompatibilityCheck::BackwardCompatible => write!(f, "backward_compatible"),
101            CompatibilityCheck::BreakingChanges => write!(f, "breaking_changes"),
102        }
103    }
104}
105
106/// compat.lock.toml 文件结构
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct CompatLockFile {
109    /// 文件头注释信息
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub _comment: Option<String>,
112
113    /// 协商记录列表
114    #[serde(default)]
115    pub negotiation: Vec<NegotiationEntry>,
116}
117
118impl CompatLockFile {
119    /// 创建新的空 lock 文件
120    pub fn new() -> Self {
121        Self {
122            _comment: Some(
123                "This file indicates the system is in SUB-HEALTHY state.\n\
124                 Consider running 'actr install --force-update' to update dependencies."
125                    .to_string(),
126            ),
127            negotiation: Vec::new(),
128        }
129    }
130
131    /// 从文件加载
132    pub async fn load(base_path: &Path) -> Result<Option<Self>, CompatLockError> {
133        let file_path = base_path.join(COMPAT_LOCK_FILENAME);
134
135        if !file_path.exists() {
136            return Ok(None);
137        }
138
139        let content =
140            fs::read_to_string(&file_path)
141                .await
142                .map_err(|e| CompatLockError::IoError {
143                    path: file_path.clone(),
144                    source: e,
145                })?;
146
147        let lock_file: Self =
148            toml::from_str(&content).map_err(|e| CompatLockError::ParseError {
149                path: file_path,
150                source: e,
151            })?;
152
153        Ok(Some(lock_file))
154    }
155
156    /// 保存到文件
157    pub async fn save(&self, base_path: &Path) -> Result<(), CompatLockError> {
158        // 确保目录存在(临时目录可能不存在)
159        if !base_path.exists() {
160            fs::create_dir_all(base_path)
161                .await
162                .map_err(|e| CompatLockError::IoError {
163                    path: base_path.to_path_buf(),
164                    source: e,
165                })?;
166            debug!(
167                "Created compat.lock cache directory: {}",
168                base_path.display()
169            );
170        }
171
172        let file_path = base_path.join(COMPAT_LOCK_FILENAME);
173
174        let content = toml::to_string_pretty(self)
175            .map_err(|e| CompatLockError::SerializeError { source: e })?;
176
177        // 添加文件头注释
178        let full_content = format!(
179            "# compat.lock.toml - 兼容性协商缓存\n\
180             # This file indicates the system is in SUB-HEALTHY state.\n\
181             # Consider running 'actr install --force-update' to update dependencies.\n\
182             # Location: {}\n\n\
183             {content}",
184            file_path.display()
185        );
186
187        fs::write(&file_path, full_content)
188            .await
189            .map_err(|e| CompatLockError::IoError {
190                path: file_path,
191                source: e,
192            })?;
193
194        Ok(())
195    }
196
197    /// 删除 lock 文件(系统恢复健康时调用)
198    pub async fn remove(base_path: &Path) -> Result<bool, CompatLockError> {
199        let file_path = base_path.join(COMPAT_LOCK_FILENAME);
200
201        if file_path.exists() {
202            fs::remove_file(&file_path)
203                .await
204                .map_err(|e| CompatLockError::IoError {
205                    path: file_path,
206                    source: e,
207                })?;
208            Ok(true)
209        } else {
210            Ok(false)
211        }
212    }
213
214    /// 查找服务的协商记录
215    pub fn find_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
216        self.negotiation
217            .iter()
218            .find(|e| e.service_name == service_name)
219    }
220
221    /// 查找未过期的协商记录
222    pub fn find_valid_entry(&self, service_name: &str) -> Option<&NegotiationEntry> {
223        let now = Utc::now();
224        self.negotiation
225            .iter()
226            .find(|e| e.service_name == service_name && e.expires_at > now)
227    }
228
229    /// 添加或更新协商记录
230    pub fn upsert_entry(&mut self, entry: NegotiationEntry) {
231        // 移除已存在的同名记录
232        self.negotiation
233            .retain(|e| e.service_name != entry.service_name);
234        // 添加新记录
235        self.negotiation.push(entry);
236    }
237
238    /// 清理过期的记录
239    pub fn cleanup_expired(&mut self) -> usize {
240        let now = Utc::now();
241        let before = self.negotiation.len();
242        self.negotiation.retain(|e| e.expires_at > now);
243        before - self.negotiation.len()
244    }
245
246    /// 检查文件是否存在(即系统是否处于亚健康状态)
247    pub async fn exists(base_path: &Path) -> bool {
248        base_path.join(COMPAT_LOCK_FILENAME).exists()
249    }
250
251    /// 检查是否有任何有效的非精确匹配记录(亚健康状态)
252    pub fn is_sub_healthy(&self) -> bool {
253        let now = Utc::now();
254        self.negotiation.iter().any(|e| {
255            e.expires_at > now && e.compatibility_check == CompatibilityCheck::BackwardCompatible
256        })
257    }
258}
259
260impl NegotiationEntry {
261    /// 创建新的协商记录
262    pub fn new(
263        service_name: String,
264        requested_fingerprint: String,
265        resolved_fingerprint: String,
266        compatibility_check: CompatibilityCheck,
267    ) -> Self {
268        let now = Utc::now();
269        Self {
270            service_name,
271            requested_fingerprint,
272            resolved_fingerprint,
273            compatibility_check,
274            negotiated_at: now,
275            expires_at: now + Duration::hours(DEFAULT_TTL_HOURS),
276        }
277    }
278
279    /// 检查是否已过期
280    pub fn is_expired(&self) -> bool {
281        Utc::now() > self.expires_at
282    }
283}
284
285/// compat.lock 相关错误
286#[derive(Debug, thiserror::Error)]
287pub enum CompatLockError {
288    #[error("IO error at {path}: {source}")]
289    IoError {
290        path: PathBuf,
291        #[source]
292        source: std::io::Error,
293    },
294
295    #[error("Parse error at {path}: {source}")]
296    ParseError {
297        path: PathBuf,
298        #[source]
299        source: toml::de::Error,
300    },
301
302    #[error("Serialize error: {source}")]
303    SerializeError {
304        #[source]
305        source: toml::ser::Error,
306    },
307}
308
309/// 兼容性协商管理器 - 运行时使用
310pub struct CompatLockManager {
311    /// lock 文件所在目录(计算得出的临时目录路径)
312    base_path: PathBuf,
313    /// 项目根目录(用于日志记录)
314    #[allow(dead_code)]
315    project_root: PathBuf,
316    /// 缓存的 lock 文件内容
317    cached: Option<CompatLockFile>,
318}
319
320impl CompatLockManager {
321    /// 创建新的管理器
322    ///
323    /// # Arguments
324    /// * `project_root` - 项目根目录的路径,用于计算唯一的缓存目录
325    ///
326    /// # 存储位置
327    /// 文件将存储在 `<temp_dir>/actr/<project_hash>/compat.lock.toml`
328    pub fn new(project_root: PathBuf) -> Self {
329        let base_path = get_compat_lock_dir(&project_root);
330        debug!(
331            "CompatLockManager initialized: project_root={}, cache_dir={}",
332            project_root.display(),
333            base_path.display()
334        );
335        Self {
336            base_path,
337            project_root,
338            cached: None,
339        }
340    }
341
342    /// 获取 compat.lock 文件的存储目录
343    pub fn cache_dir(&self) -> &Path {
344        &self.base_path
345    }
346
347    /// 加载 lock 文件
348    pub async fn load(&mut self) -> Result<Option<&CompatLockFile>, CompatLockError> {
349        self.cached = CompatLockFile::load(&self.base_path).await?;
350        Ok(self.cached.as_ref())
351    }
352
353    /// 获取缓存的 lock 文件
354    pub fn get_cached(&self) -> Option<&CompatLockFile> {
355        self.cached.as_ref()
356    }
357
358    /// 记录协商结果
359    ///
360    /// 当发现服务时调用:
361    /// - 如果是精确匹配,尝试删除对应的协商记录
362    /// - 如果是兼容匹配,添加/更新协商记录
363    pub async fn record_negotiation(
364        &mut self,
365        service_name: &str,
366        requested_fingerprint: &str,
367        resolved_fingerprint: &str,
368        is_exact_match: bool,
369        compatibility_check: CompatibilityCheck,
370    ) -> Result<(), CompatLockError> {
371        if is_exact_match {
372            // 精确匹配:尝试删除旧的协商记录
373            if let Some(ref mut lock_file) = self.cached {
374                lock_file
375                    .negotiation
376                    .retain(|e| e.service_name != service_name);
377
378                // 如果所有记录都被清除,删除文件
379                if lock_file.negotiation.is_empty() {
380                    CompatLockFile::remove(&self.base_path).await?;
381                    self.cached = None;
382                    info!("✅ SYSTEM HEALTHY: 所有依赖精确匹配,已删除 compat.lock.toml");
383                } else {
384                    lock_file.save(&self.base_path).await?;
385                }
386            }
387        } else {
388            // 兼容匹配:记录到 lock 文件
389            let entry = NegotiationEntry::new(
390                service_name.to_string(),
391                requested_fingerprint.to_string(),
392                resolved_fingerprint.to_string(),
393                compatibility_check,
394            );
395
396            let lock_file = self.cached.get_or_insert_with(CompatLockFile::new);
397            lock_file.upsert_entry(entry);
398            lock_file.save(&self.base_path).await?;
399
400            warn!(
401                "🟡 SYSTEM SUB-HEALTHY: Service '{}' using compatible fingerprint ({}) instead of exact match ({}). \
402                 Run 'actr install --force-update' to restore health.",
403                service_name,
404                &resolved_fingerprint[..20.min(resolved_fingerprint.len())],
405                &requested_fingerprint[..20.min(requested_fingerprint.len())],
406            );
407        }
408
409        Ok(())
410    }
411
412    /// 查找已缓存的兼容版本(用于快速启动)
413    pub fn find_cached_compatible(
414        &self,
415        service_name: &str,
416        requested_fingerprint: &str,
417    ) -> Option<&NegotiationEntry> {
418        self.cached.as_ref().and_then(|lock_file| {
419            lock_file
420                .find_valid_entry(service_name)
421                .filter(|entry| entry.requested_fingerprint == requested_fingerprint)
422        })
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use tempfile::TempDir;
430
431    #[tokio::test]
432    async fn test_compat_lock_file_roundtrip() {
433        let temp_dir = TempDir::new().unwrap();
434        let base_path = temp_dir.path();
435
436        // 创建并保存
437        let mut lock_file = CompatLockFile::new();
438        lock_file.upsert_entry(NegotiationEntry::new(
439            "user-service".to_string(),
440            "sha256:old".to_string(),
441            "sha256:new".to_string(),
442            CompatibilityCheck::BackwardCompatible,
443        ));
444        lock_file.save(base_path).await.unwrap();
445
446        // 验证文件存在
447        assert!(CompatLockFile::exists(base_path).await);
448
449        // 重新加载
450        let loaded = CompatLockFile::load(base_path).await.unwrap().unwrap();
451        assert_eq!(loaded.negotiation.len(), 1);
452        assert_eq!(loaded.negotiation[0].service_name, "user-service");
453        assert!(loaded.is_sub_healthy());
454    }
455
456    #[tokio::test]
457    async fn test_compat_lock_manager() {
458        let temp_dir = TempDir::new().unwrap();
459        // 使用临时目录作为项目根目录
460        let project_root = temp_dir.path().to_path_buf();
461
462        let mut manager = CompatLockManager::new(project_root.clone());
463
464        // 验证缓存目录在系统临时目录下
465        let cache_dir = manager.cache_dir().to_path_buf();
466        assert!(cache_dir.starts_with(std::env::temp_dir()));
467        assert!(cache_dir.to_string_lossy().contains("actr"));
468
469        // 记录兼容匹配
470        manager
471            .record_negotiation(
472                "user-service",
473                "sha256:old",
474                "sha256:new",
475                false,
476                CompatibilityCheck::BackwardCompatible,
477            )
478            .await
479            .unwrap();
480
481        // 验证文件存在于计算出的缓存目录
482        assert!(CompatLockFile::exists(&cache_dir).await);
483
484        // 验证文件不在项目目录中
485        assert!(!project_root.join(COMPAT_LOCK_FILENAME).exists());
486
487        // 查找缓存
488        let entry = manager.find_cached_compatible("user-service", "sha256:old");
489        assert!(entry.is_some());
490
491        // 精确匹配后应该删除记录
492        manager
493            .record_negotiation(
494                "user-service",
495                "sha256:exact",
496                "sha256:exact",
497                true,
498                CompatibilityCheck::ExactMatch,
499            )
500            .await
501            .unwrap();
502
503        // 文件应该被删除(因为没有其他记录)
504        assert!(!CompatLockFile::exists(&cache_dir).await);
505    }
506
507    #[test]
508    fn test_project_hash_deterministic() {
509        let path1 = PathBuf::from("/tmp/test-project");
510        let path2 = PathBuf::from("/tmp/test-project");
511        let path3 = PathBuf::from("/tmp/other-project");
512
513        let hash1 = compute_project_hash(&path1);
514        let hash2 = compute_project_hash(&path2);
515        let hash3 = compute_project_hash(&path3);
516
517        // 相同路径应该产生相同哈希
518        assert_eq!(hash1, hash2);
519        // 不同路径应该产生不同哈希
520        assert_ne!(hash1, hash3);
521        // 哈希应该是16个十六进制字符
522        assert_eq!(hash1.len(), 16);
523    }
524}