Skip to main content

mur_common/skill/
stats.rs

1//! Per-skill runtime statistics. **Not** signed, **not** part of the
2//! publisher manifest. Lives at `<MUR_HOME>/skills/<name>/stats.json`
3//! and is rebuildable from the JSONL trace log via
4//! `mur skill reindex-stats`.
5//!
6//! ## Security
7//!
8//! `stats.json` is host-local mutable state and is **explicitly outside
9//! the DSSE signature scope** (see §2.2 Layer 1 of the skill ecosystem
10//! design). A skill's signature covers `skill.yaml` only. Stats can be
11//! deleted or rebuilt (`mur skill reindex-stats`) without affecting
12//! trust.
13
14use anyhow::{Context, Result};
15use chrono::{DateTime, Utc};
16use fd_lock::RwLock;
17use serde::{Deserialize, Serialize};
18use std::fs::OpenOptions;
19use std::path::{Path, PathBuf};
20use tempfile::NamedTempFile;
21
22pub const STATS_SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum LifecycleState {
27    #[default]
28    Draft,
29    Emerging,
30    Stable,
31    Canonical,
32    Deprecated,
33    Archived,
34    /// Terminal state: `mur skill sweep` deleted the skill's on-disk directory
35    /// after the `archive_destroy_grace_days` grace period. The stats file
36    /// itself is removed immediately after this transition is written, so this
37    /// variant is never read back from a live install — it only appears
38    /// transiently in `SweepReport::transitions` and in tracing events.
39    Destroyed,
40}
41
42/// Sidecar stats for an installed skill. **Not part of the signed manifest.**
43///
44/// Schema evolution policy: additive only. New fields MUST be marked
45/// `#[serde(default)]` so older `mur` builds reading newer files (and newer
46/// builds reading older files) parse cleanly without migration. Do not
47/// pre-reserve fields without a producer — empty defaults create semantic
48/// ambiguity ("never set" vs "set to empty"). Add fields when their
49/// callers exist.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkillStats {
52    pub schema_version: u32,
53    pub skill_name: String,
54    pub skill_version: String,
55    /// SHA-256 of the manifest content at the time these stats were
56    /// (re)initialised. A mismatch on load tells us the skill was
57    /// reinstalled — see `reset_on_manifest_change()`.
58    pub manifest_digest: String,
59
60    pub lifecycle_state: LifecycleState,
61    pub lifecycle_changed_at: DateTime<Utc>,
62    pub pinned: bool,
63    #[serde(default)]
64    pub pinned_reason: String,
65
66    pub usage_count: u64,
67    pub success_count: u64,
68    pub failure_count: u64,
69
70    pub last_used_at: Option<DateTime<Utc>>,
71    pub last_success_at: Option<DateTime<Utc>>,
72    pub first_successful_use_at: Option<DateTime<Utc>>,
73
74    /// Confidence at the moment of the most recent successful use (or
75    /// most recent promotion — see `lifecycle::on_promotion`). Decay is
76    /// computed *from this anchor*, never incrementally — keeps the
77    /// value numerically stable and idempotent on read.
78    pub anchor_confidence: f64,
79
80    /// Watermark for incremental reindex — the trace timestamp that
81    /// these stats have already absorbed. `mur skill reindex-stats`
82    /// resumes from here.
83    pub rebuilt_from_trace_through: Option<DateTime<Utc>>,
84
85    /// Count of inject-time `Resolution::Unresolved` outcomes for this skill.
86    /// A spike here means the skill declares intents that no longer match the
87    /// agent's MCP inventory — doctor's `intent-resolvable` check surfaces this.
88    #[serde(default)]
89    pub resolution_misses: u64,
90
91    /// Timestamp of the most recent human curation event
92    /// (`mur.skill.curated`). `None` until a human has reviewed an
93    /// LLM-extracted skill. Opens the provenance gate (see
94    /// `lifecycle::cap_for_provenance`). `#[serde(default)]` keeps older
95    /// stats files parsing.
96    #[serde(default)]
97    pub curated_at: Option<DateTime<Utc>>,
98}
99
100impl SkillStats {
101    pub fn new(
102        skill_name: &str,
103        skill_version: &str,
104        manifest_digest: &str,
105        now: DateTime<Utc>,
106    ) -> Self {
107        Self {
108            schema_version: STATS_SCHEMA_VERSION,
109            skill_name: skill_name.to_string(),
110            skill_version: skill_version.to_string(),
111            manifest_digest: manifest_digest.to_string(),
112            lifecycle_state: LifecycleState::default(),
113            lifecycle_changed_at: now,
114            pinned: false,
115            pinned_reason: String::new(),
116            usage_count: 0,
117            success_count: 0,
118            failure_count: 0,
119            last_used_at: None,
120            last_success_at: None,
121            first_successful_use_at: None,
122            anchor_confidence: 1.0,
123            rebuilt_from_trace_through: None,
124            resolution_misses: 0,
125            curated_at: None,
126        }
127    }
128
129    pub fn path(mur_home: &Path, skill_name: &str) -> PathBuf {
130        mur_home.join("skills").join(skill_name).join("stats.json")
131    }
132
133    /// Per-agent stats path: <MUR_HOME>/agents/<agent>/skills/<name>/stats.json
134    pub fn path_agent(mur_home: &Path, agent: &str, skill_name: &str) -> PathBuf {
135        mur_home
136            .join("agents")
137            .join(agent)
138            .join("skills")
139            .join(skill_name)
140            .join("stats.json")
141    }
142
143    /// Read the sidecar, or return `None` if absent. Lock-free — fine
144    /// for read-mostly callers (doctor, info, stats). Concurrent writers
145    /// going through `merge_in_place` will not corrupt the file because
146    /// they hold the exclusive lock during the write window.
147    pub fn load(path: &Path) -> Result<Option<Self>> {
148        match std::fs::read_to_string(path) {
149            Ok(s) => {
150                let stats: Self = serde_json::from_str(&s).context("deserialise stats.json")?;
151                Ok(Some(stats))
152            }
153            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
154            Err(e) => Err(e).context("read stats.json"),
155        }
156    }
157
158    /// Read-merge-write under an exclusive `fd-lock`. `merge_fn` is
159    /// called with the loaded value (or the supplied default if none
160    /// exists) and is responsible for applying the delta. The lock
161    /// window is microseconds — counter increments only.
162    pub fn merge_in_place(
163        path: &Path,
164        default: impl FnOnce() -> Self,
165        merge_fn: impl FnOnce(&mut Self) -> Result<()>,
166    ) -> Result<()> {
167        // Lock on a sidecar lockfile, not stats.json itself — POSIX
168        // flock(2) on the data file would race with rename. Same
169        // pattern as git/index.lock.
170        let lock_path = path.with_extension("lock");
171        let parent = path.parent().context("stats path has no parent")?;
172        std::fs::create_dir_all(parent).ok();
173
174        let mut lock_file = RwLock::new(
175            OpenOptions::new()
176                .create(true)
177                .truncate(true)
178                .write(true)
179                .read(true)
180                .open(&lock_path)
181                .context("open stats lockfile")?,
182        );
183        let _guard = lock_file.write().context("acquire stats lock")?;
184
185        let mut stats = Self::load(path)?.unwrap_or_else(default);
186        merge_fn(&mut stats)?;
187
188        let tmp = NamedTempFile::new_in(parent).context("create temp file for stats")?;
189        serde_json::to_writer_pretty(&tmp, &stats).context("serialise stats")?;
190        tmp.persist(path).context("persist stats")?;
191        Ok(())
192    }
193
194    /// Returns true if the loaded stats refer to a different manifest
195    /// digest than the one currently installed. Callers (the aggregator
196    /// and reindex) should `reset()` in that case rather than carry
197    /// counters across an upgrade.
198    ///
199    /// A version bump resets `usage_count` / `success_count` /
200    /// `failure_count` but **preserves** `pinned`,
201    /// `first_successful_use_at`, and `lifecycle_state` (a Canonical
202    /// skill bumping to 1.2.0 should not regress to Draft).
203    pub fn is_stale(&self, current_digest: &str) -> bool {
204        self.manifest_digest != current_digest
205    }
206
207    /// Reset counters for a manifest change, preserving pinned state
208    /// and first-success timestamp.
209    pub fn reset_for_new_manifest(
210        &mut self,
211        new_version: &str,
212        new_digest: &str,
213        now: DateTime<Utc>,
214    ) {
215        self.skill_version = new_version.to_string();
216        self.manifest_digest = new_digest.to_string();
217        self.usage_count = 0;
218        self.success_count = 0;
219        self.failure_count = 0;
220        self.last_used_at = None;
221        self.last_success_at = None;
222        self.anchor_confidence = 1.0;
223        self.rebuilt_from_trace_through = None;
224        self.lifecycle_changed_at = now;
225        // Preserve: pinned, pinned_reason, first_successful_use_at, lifecycle_state
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::thread;
233
234    fn temp_stats_path() -> (tempfile::TempDir, PathBuf) {
235        let dir = tempfile::tempdir().unwrap();
236        let path = dir.path().join("test_skill").join("stats.json");
237        let parent = path.parent().unwrap();
238        std::fs::create_dir_all(parent).unwrap();
239        (dir, path)
240    }
241
242    fn dummy_stats(name: &str) -> SkillStats {
243        SkillStats::new(name, "1.0.0", "abc123", Utc::now())
244    }
245
246    #[test]
247    fn load_returns_none_for_missing_path() {
248        let (_dir, path) = temp_stats_path();
249        let result = SkillStats::load(&path).unwrap();
250        assert!(result.is_none());
251    }
252
253    #[test]
254    fn load_returns_stats_for_valid_file() {
255        let (_dir, path) = temp_stats_path();
256        let stats = dummy_stats("test-skill");
257        std::fs::write(&path, serde_json::to_string_pretty(&stats).unwrap()).unwrap();
258        let loaded = SkillStats::load(&path).unwrap().unwrap();
259        assert_eq!(loaded.skill_name, "test-skill");
260        assert_eq!(loaded.usage_count, 0);
261    }
262
263    #[test]
264    fn merge_in_place_counter_increment() {
265        let (_dir, path) = temp_stats_path();
266        let skill_name = "merge-test".to_string();
267        let default = || dummy_stats(&skill_name);
268
269        // First merge: increment usage
270        SkillStats::merge_in_place(&path, default, |s| {
271            s.usage_count += 1;
272            Ok(())
273        })
274        .unwrap();
275
276        let loaded = SkillStats::load(&path).unwrap().unwrap();
277        assert_eq!(loaded.usage_count, 1);
278
279        // Second merge: increment again
280        SkillStats::merge_in_place(
281            &path,
282            || panic!("default should not be called"),
283            |s| {
284                s.usage_count += 2;
285                Ok(())
286            },
287        )
288        .unwrap();
289
290        let loaded = SkillStats::load(&path).unwrap().unwrap();
291        assert_eq!(loaded.usage_count, 3);
292    }
293
294    #[test]
295    fn concurrent_merge_both_increments_commit() {
296        let (_dir, path) = temp_stats_path();
297        let skill_name = "concurrent-test".to_string();
298        let path = std::path::PathBuf::from(path); // decouple from tempdir lifetime
299        let path2 = path.clone();
300
301        // Init the file
302        SkillStats::merge_in_place(&path, || dummy_stats(&skill_name), |_| Ok(())).unwrap();
303
304        let t1 = thread::spawn(move || {
305            SkillStats::merge_in_place(
306                &path,
307                || panic!("default should not be called"),
308                |s| {
309                    s.usage_count += 1;
310                    Ok(())
311                },
312            )
313            .unwrap();
314        });
315        let t2 = thread::spawn(move || {
316            SkillStats::merge_in_place(
317                &path2,
318                || panic!("default should not be called"),
319                |s| {
320                    s.usage_count += 2;
321                    Ok(())
322                },
323            )
324            .unwrap();
325        });
326
327        t1.join().unwrap();
328        t2.join().unwrap();
329
330        let loaded = SkillStats::load(&_dir.path().join("test_skill").join("stats.json"))
331            .unwrap()
332            .unwrap();
333        // Both increments should have committed (commutative counters)
334        assert_eq!(loaded.usage_count, 3);
335    }
336
337    #[test]
338    fn is_stale_detects_digest_mismatch() {
339        let stats = dummy_stats("test");
340        assert!(!stats.is_stale("abc123"));
341        assert!(stats.is_stale("different"));
342    }
343
344    #[test]
345    fn schema_version_1_deserialises_fixture() {
346        let fixture = r#"{
347            "schema_version": 1,
348            "skill_name": "research-patterns",
349            "skill_version": "2.3.0",
350            "manifest_digest": "abcdef",
351            "lifecycle_state": "emerging",
352            "lifecycle_changed_at": "2026-05-25T00:00:00Z",
353            "pinned": false,
354            "pinned_reason": "",
355            "usage_count": 42,
356            "success_count": 38,
357            "failure_count": 4,
358            "last_used_at": "2026-05-25T12:00:00Z",
359            "last_success_at": "2026-05-25T11:00:00Z",
360            "first_successful_use_at": "2026-05-01T00:00:00Z",
361            "anchor_confidence": 0.95,
362            "rebuilt_from_trace_through": "2026-05-25T10:00:00Z"
363        }"#;
364        let stats: SkillStats = serde_json::from_str(fixture).unwrap();
365        assert_eq!(stats.schema_version, 1);
366        assert_eq!(stats.lifecycle_state, LifecycleState::Emerging);
367        assert_eq!(stats.usage_count, 42);
368        assert_eq!(stats.anchor_confidence, 0.95);
369        assert!(stats.last_used_at.is_some());
370    }
371
372    #[test]
373    fn reset_for_new_manifest_preserves_pinned_and_state() {
374        let mut stats = SkillStats {
375            pinned: true,
376            pinned_reason: "critical".into(),
377            lifecycle_state: LifecycleState::Canonical,
378            first_successful_use_at: Some(Utc::now()),
379            usage_count: 100,
380            success_count: 95,
381            failure_count: 5,
382            ..dummy_stats("test")
383        };
384        stats.reset_for_new_manifest("2.0.0", "newdigest", Utc::now());
385        assert_eq!(stats.skill_version, "2.0.0");
386        assert_eq!(stats.usage_count, 0);
387        assert!(stats.pinned);
388        assert_eq!(stats.lifecycle_state, LifecycleState::Canonical);
389        assert!(stats.first_successful_use_at.is_some());
390    }
391
392    #[test]
393    fn curated_at_defaults_to_none_and_is_backward_compatible() {
394        // A SkillStats JSON written before this field existed must still parse.
395        let legacy = r#"{
396            "schema_version": 1, "skill_name": "x", "skill_version": "1",
397            "manifest_digest": "d", "lifecycle_state": "draft",
398            "lifecycle_changed_at": "2026-01-01T00:00:00Z", "pinned": false,
399            "usage_count": 0, "success_count": 0, "failure_count": 0,
400            "anchor_confidence": 1.0
401        }"#;
402        let s: SkillStats = serde_json::from_str(legacy).unwrap();
403        assert_eq!(s.curated_at, None);
404    }
405}