Skip to main content

mur_common/skill/
event_log.rs

1//! Per-skill append-only event log (`~/.mur/skills/<name>/events.jsonl`).
2//! Each line is a JSON-serialized `SkillEvent`. Used by fleet-sync for
3//! set-union merge of evolved usage state across devices.
4//!
5//! Also provides manifest conflict resolution via Last-Writer-Wins (LWW)
6//! for fleet-sync: when two devices have divergent manifests, the one
7//! with the later `updated_at` timestamp wins.
8
9use crate::skill::manifest::Skill;
10use crate::skill::stats::SkillStats;
11use anyhow::Result;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(tag = "kind", rename_all = "snake_case")]
20pub enum SkillEvent {
21    Retrieval {
22        ts: DateTime<Utc>,
23        device_id: String,
24    },
25    Execution {
26        ts: DateTime<Utc>,
27        device_id: String,
28        /// "success" | "failure"
29        outcome: String,
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        error: Option<String>,
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        step: Option<String>,
34        // ── Run-ledger enrichment (workflow-engine v2 P2; all default so
35        //    existing events.jsonl lines keep parsing and fleet-sync's
36        //    dedup_key (ts+kind+device) is unaffected) ──
37        #[serde(default, skip_serializing_if = "Option::is_none")]
38        duration_ms: Option<u64>,
39        #[serde(default, skip_serializing_if = "Option::is_none")]
40        exit_code: Option<i32>,
41        /// "workflow" (the skill is broken) | "env" (network/credentials/…).
42        /// The Broken fast-path (P4) only triggers on "workflow" with
43        /// confidence ≥ threshold.
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        env_class: Option<String>,
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        confidence: Option<f64>,
48        /// "manual" | "schedule" | "agent"
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        trigger: Option<String>,
51    },
52    Dismissed {
53        ts: DateTime<Utc>,
54        device_id: String,
55    },
56    Superseded {
57        ts: DateTime<Utc>,
58        device_id: String,
59    },
60}
61
62impl SkillEvent {
63    /// Stable key for set-dedup: timestamp-micros + kind + device.
64    pub fn dedup_key(&self) -> String {
65        match self {
66            Self::Retrieval { ts, device_id } => {
67                format!("{}:retrieval:{}", ts.timestamp_micros(), device_id)
68            }
69            Self::Execution { ts, device_id, .. } => {
70                format!("{}:execution:{}", ts.timestamp_micros(), device_id)
71            }
72            Self::Dismissed { ts, device_id } => {
73                format!("{}:dismissed:{}", ts.timestamp_micros(), device_id)
74            }
75            Self::Superseded { ts, device_id } => {
76                format!("{}:superseded:{}", ts.timestamp_micros(), device_id)
77            }
78        }
79    }
80
81    pub fn ts(&self) -> DateTime<Utc> {
82        match self {
83            Self::Retrieval { ts, .. }
84            | Self::Execution { ts, .. }
85            | Self::Dismissed { ts, .. }
86            | Self::Superseded { ts, .. } => *ts,
87        }
88    }
89}
90
91pub fn event_log_path(mur_home: &Path, skill_name: &str) -> PathBuf {
92    mur_home
93        .join("skills")
94        .join(skill_name)
95        .join("events.jsonl")
96}
97
98pub fn append_event(path: &Path, event: &SkillEvent) -> Result<()> {
99    use fs2::FileExt;
100    use std::io::{Seek, SeekFrom};
101
102    if let Some(parent) = path.parent() {
103        std::fs::create_dir_all(parent)?;
104    }
105    let line = serde_json::to_string(event)?;
106    // Open read+write (not `append(true)`) and take an exclusive flock,
107    // seeking to end ourselves — matches multimodal::ledger::append.
108    // `append(true)` alone doesn't request enough access for `LockFileEx`
109    // on Windows, and without a lock concurrent writers can interleave
110    // their `write()` syscalls and tear a line.
111    let mut f = std::fs::OpenOptions::new()
112        .create(true)
113        .read(true)
114        .write(true)
115        .truncate(false)
116        .open(path)?;
117    f.lock_exclusive()?;
118    f.seek(SeekFrom::End(0))?;
119    // One write() call for the whole line (content + newline) so a torn
120    // write can't happen even if the lock were ever dropped.
121    f.write_all(format!("{line}\n").as_bytes())?;
122    f.unlock()?;
123    Ok(())
124}
125
126pub fn read_events(path: &Path) -> Result<Vec<SkillEvent>> {
127    match std::fs::read_to_string(path) {
128        Ok(s) => parse_events_jsonl(&s),
129        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
130        Err(e) => Err(anyhow::Error::from(e)),
131    }
132}
133
134pub fn parse_events_jsonl(raw: &str) -> Result<Vec<SkillEvent>> {
135    raw.lines()
136        .filter(|l| !l.is_empty())
137        .map(|l| serde_json::from_str(l).map_err(anyhow::Error::from))
138        .collect()
139}
140
141/// Set-union of two event logs, deduped by `dedup_key`, sorted by timestamp.
142/// Commutative and idempotent.
143pub fn union_events(mut a: Vec<SkillEvent>, b: Vec<SkillEvent>) -> Vec<SkillEvent> {
144    let seen: HashSet<String> = a.iter().map(|e| e.dedup_key()).collect();
145    for event in b {
146        if !seen.contains(&event.dedup_key()) {
147            a.push(event);
148        }
149    }
150    a.sort_by_key(|e| e.ts());
151    a
152}
153
154/// Apply a slice of new events to an existing `SkillStats`, updating only
155/// usage counters. Lifecycle state, pinned, and anchor_confidence are
156/// preserved — they are managed by the lifecycle module, not by events.
157pub fn apply_new_events_to_stats(stats: &mut SkillStats, new_events: &[SkillEvent]) {
158    for event in new_events {
159        match event {
160            SkillEvent::Retrieval { ts, .. } => {
161                stats.usage_count += 1;
162                stats.last_used_at = Some(stats.last_used_at.map(|e| e.max(*ts)).unwrap_or(*ts));
163            }
164            SkillEvent::Execution { ts, outcome, .. } => {
165                stats.usage_count += 1;
166                stats.last_used_at = Some(stats.last_used_at.map(|e| e.max(*ts)).unwrap_or(*ts));
167                if outcome == "success" {
168                    stats.success_count += 1;
169                    stats.last_success_at =
170                        Some(stats.last_success_at.map(|e| e.max(*ts)).unwrap_or(*ts));
171                    if stats.first_successful_use_at.is_none() {
172                        stats.first_successful_use_at = Some(*ts);
173                    }
174                } else {
175                    stats.failure_count += 1;
176                }
177            }
178            SkillEvent::Dismissed { .. } | SkillEvent::Superseded { .. } => {}
179        }
180    }
181}
182
183/// Outcome of one workflow/skill run, recorded into the per-skill ledger.
184pub struct RunRecord<'a> {
185    /// true = success
186    pub success: bool,
187    pub duration_ms: Option<u64>,
188    pub exit_code: Option<i32>,
189    /// stderr (or combined output) of the failing step; used to classify
190    /// workflow-vs-environment failure. Ignored on success.
191    pub stderr: Option<&'a str>,
192    /// Step id/description that failed, if any.
193    pub failed_step: Option<String>,
194    /// "manual" | "schedule" | "agent"
195    pub trigger: &'a str,
196    /// Explicit user override of the env classification
197    /// (`mur run --env-class workflow|env`).
198    pub env_class_override: Option<&'a str>,
199}
200
201/// Append one enriched Execution event for a completed run — the run-ledger
202/// write path (workflow-engine v2 P2). Returns the event written.
203pub fn record_run(
204    mur_home: &Path,
205    skill_name: &str,
206    device_id: &str,
207    rec: &RunRecord<'_>,
208) -> Result<SkillEvent> {
209    let (env_class, confidence) = if rec.success {
210        (None, None)
211    } else if let Some(forced) = rec.env_class_override {
212        (Some(forced.to_string()), Some(1.0))
213    } else {
214        let c = crate::skill::env_class::classify_failure(rec.stderr.unwrap_or(""));
215        (Some(c.class.to_string()), Some(c.confidence))
216    };
217
218    let event = SkillEvent::Execution {
219        ts: Utc::now(),
220        device_id: device_id.to_string(),
221        outcome: if rec.success { "success" } else { "failure" }.to_string(),
222        error: (!rec.success)
223            .then(|| rec.stderr.map(|s| s.chars().take(500).collect()))
224            .flatten(),
225        step: rec.failed_step.clone(),
226        duration_ms: rec.duration_ms,
227        exit_code: rec.exit_code,
228        env_class,
229        confidence,
230        trigger: Some(rec.trigger.to_string()),
231    };
232    append_event(&event_log_path(mur_home, skill_name), &event)?;
233    Ok(event)
234}
235
236/// Resolve manifest conflict via Last-Writer-Wins (LWW).
237/// Returns the winning skill and the reason (local_wins, remote_wins, or force_local).
238pub fn resolve_manifest_lww(
239    local: Skill,
240    remote: Skill,
241    force_local: bool,
242) -> (Skill, &'static str) {
243    if force_local {
244        return (local, "force_local");
245    }
246    if remote.manifest.updated_at > local.manifest.updated_at {
247        (remote, "remote_newer")
248    } else {
249        (local, "local_newer_or_equal")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use tempfile::tempdir;
257
258    #[test]
259    fn record_run_classifies_and_appends() {
260        let tmp = tempdir().unwrap();
261        let ev = record_run(
262            tmp.path(),
263            "deploy-api",
264            "dev-a",
265            &RunRecord {
266                success: false,
267                duration_ms: Some(1200),
268                exit_code: Some(7),
269                stderr: Some("curl: (7) Connection refused"),
270                failed_step: Some("health-check".into()),
271                trigger: "manual",
272                env_class_override: None,
273            },
274        )
275        .unwrap();
276        match &ev {
277            SkillEvent::Execution {
278                env_class, trigger, ..
279            } => {
280                assert_eq!(env_class.as_deref(), Some("env"));
281                assert_eq!(trigger.as_deref(), Some("manual"));
282            }
283            _ => panic!("wrong kind"),
284        }
285        let events = read_events(&event_log_path(tmp.path(), "deploy-api")).unwrap();
286        assert_eq!(events.len(), 1);
287
288        // Success run records no env_class.
289        let ev2 = record_run(
290            tmp.path(),
291            "deploy-api",
292            "dev-a",
293            &RunRecord {
294                success: true,
295                duration_ms: Some(900),
296                exit_code: Some(0),
297                stderr: None,
298                failed_step: None,
299                trigger: "schedule",
300                env_class_override: None,
301            },
302        )
303        .unwrap();
304        match &ev2 {
305            SkillEvent::Execution {
306                env_class, outcome, ..
307            } => {
308                assert!(env_class.is_none());
309                assert_eq!(outcome, "success");
310            }
311            _ => panic!("wrong kind"),
312        }
313    }
314
315    #[test]
316    fn legacy_execution_line_parses_and_enriched_roundtrips() {
317        // Pre-P2 line without the run-ledger fields must keep parsing.
318        let legacy = r#"{"kind":"execution","ts":"2026-05-30T00:00:00Z","device_id":"d","outcome":"success"}"#;
319        let ev: SkillEvent = serde_json::from_str(legacy).unwrap();
320        match &ev {
321            SkillEvent::Execution {
322                duration_ms,
323                env_class,
324                ..
325            } => {
326                assert!(duration_ms.is_none());
327                assert!(env_class.is_none());
328            }
329            _ => panic!("wrong kind"),
330        }
331
332        // Enriched event round-trips.
333        let enriched = SkillEvent::Execution {
334            ts: chrono::DateTime::from_timestamp(1_748_000_000, 0).unwrap(),
335            device_id: "d".into(),
336            outcome: "failure".into(),
337            error: Some("boom".into()),
338            step: Some("deploy".into()),
339            duration_ms: Some(8421),
340            exit_code: Some(1),
341            env_class: Some("workflow".into()),
342            confidence: Some(0.6),
343            trigger: Some("manual".into()),
344        };
345        let line = serde_json::to_string(&enriched).unwrap();
346        let back: SkillEvent = serde_json::from_str(&line).unwrap();
347        assert_eq!(back, enriched);
348        // dedup_key shape unchanged (ts+kind+device) — fleet-sync compatible.
349        assert!(enriched.dedup_key().ends_with(":execution:d"));
350    }
351
352    fn device() -> String {
353        "dev-a".into()
354    }
355
356    fn retrieval(ts_offset_secs: i64) -> SkillEvent {
357        let base = chrono::DateTime::from_timestamp(1_748_000_000 + ts_offset_secs, 0).unwrap();
358        SkillEvent::Retrieval {
359            ts: base,
360            device_id: device(),
361        }
362    }
363
364    fn exec_ok(ts_offset_secs: i64) -> SkillEvent {
365        let base = chrono::DateTime::from_timestamp(1_748_000_000 + ts_offset_secs, 0).unwrap();
366        SkillEvent::Execution {
367            ts: base,
368            device_id: device(),
369            outcome: "success".into(),
370            error: None,
371            step: None,
372            duration_ms: None,
373            exit_code: None,
374            env_class: None,
375            confidence: None,
376            trigger: None,
377        }
378    }
379
380    fn exec_fail(ts_offset_secs: i64) -> SkillEvent {
381        let base = chrono::DateTime::from_timestamp(1_748_000_000 + ts_offset_secs, 0).unwrap();
382        SkillEvent::Execution {
383            ts: base,
384            device_id: device(),
385            outcome: "failure".into(),
386            error: Some("oops".into()),
387            step: None,
388            duration_ms: None,
389            exit_code: None,
390            env_class: None,
391            confidence: None,
392            trigger: None,
393        }
394    }
395
396    #[test]
397    fn append_then_read_roundtrip() {
398        let dir = tempdir().unwrap();
399        let path = dir.path().join("events.jsonl");
400        append_event(&path, &retrieval(0)).unwrap();
401        append_event(&path, &exec_ok(1)).unwrap();
402        let events = read_events(&path).unwrap();
403        assert_eq!(events.len(), 2);
404    }
405
406    /// Regression test for torn/interleaved lines under concurrent writers.
407    ///
408    /// N threads each append M events to the same path with no external
409    /// synchronization; `append_event` itself must serialize the writes via
410    /// flock, otherwise two writers' `write()` syscalls can interleave and
411    /// glue/tear a line. `parse_events_jsonl` uses `.collect::<Result<_>>()`,
412    /// so a torn line makes the *whole* `read_events` call return `Err`
413    /// rather than silently dropping one entry — either way it's a real
414    /// failure, so we assert both that reading succeeds and that we get
415    /// back exactly the expected count.
416    ///
417    /// Verified this reproduces against the old `append(true)` + `writeln!`
418    /// implementation: reverting `append_event` to that shape and rerunning
419    /// this test failed within a handful of runs with
420    /// `called \`Result::unwrap()\` on an \`Err\` value: trailing characters
421    /// at line 1 column 69` — a torn/glued line that no longer parses as
422    /// JSON. It's a data race so it doesn't fail on literally every run,
423    /// but it reproduces reliably enough (a handful of tries) to be
424    /// confident it exercises the bug.
425    #[test]
426    fn concurrent_appends_produce_no_torn_lines() {
427        let dir = tempdir().unwrap();
428        let path = dir.path().join("events.jsonl");
429
430        const THREADS: i64 = 8;
431        const PER_THREAD: i64 = 25;
432
433        let handles: Vec<_> = (0..THREADS)
434            .map(|t| {
435                let path = path.clone();
436                std::thread::spawn(move || {
437                    for i in 0..PER_THREAD {
438                        let ts_offset = t * PER_THREAD + i;
439                        append_event(&path, &retrieval(ts_offset)).unwrap();
440                    }
441                })
442            })
443            .collect();
444        for h in handles {
445            h.join().unwrap();
446        }
447
448        let events = read_events(&path).unwrap();
449        assert_eq!(events.len(), (THREADS * PER_THREAD) as usize);
450    }
451
452    #[test]
453    fn union_deduplicates_identical_events() {
454        let a = vec![retrieval(0), exec_ok(1)];
455        let b = vec![exec_ok(1), exec_fail(2)];
456        let merged = union_events(a, b);
457        assert_eq!(merged.len(), 3); // dedup exec_ok(1)
458    }
459
460    #[test]
461    fn union_is_commutative() {
462        let a = vec![retrieval(0), exec_ok(1)];
463        let b = vec![exec_ok(1), exec_fail(2)];
464        let ab = union_events(a.clone(), b.clone());
465        let ba = union_events(b, a);
466        let ab_keys: Vec<_> = ab.iter().map(|e| e.dedup_key()).collect();
467        let ba_keys: Vec<_> = ba.iter().map(|e| e.dedup_key()).collect();
468        assert_eq!(ab_keys, ba_keys);
469    }
470
471    #[test]
472    fn apply_new_events_updates_counters() {
473        use crate::skill::stats::SkillStats;
474        use chrono::Utc;
475        let mut stats = SkillStats::new("test-skill", "1.0.0", "digest", Utc::now());
476        let events = vec![exec_ok(1), exec_fail(2), retrieval(3)];
477        apply_new_events_to_stats(&mut stats, &events);
478        assert_eq!(stats.usage_count, 3);
479        assert_eq!(stats.success_count, 1);
480        assert_eq!(stats.failure_count, 1);
481        assert!(stats.last_success_at.is_some());
482        assert!(stats.first_successful_use_at.is_some());
483    }
484
485    #[test]
486    fn read_events_returns_empty_for_missing_file() {
487        let dir = tempdir().unwrap();
488        let events = read_events(&dir.path().join("missing.jsonl")).unwrap();
489        assert!(events.is_empty());
490    }
491
492    #[test]
493    fn parse_events_jsonl_handles_multiline() {
494        let raw = "{\"kind\":\"retrieval\",\"ts\":\"2026-05-30T00:00:00Z\",\"device_id\":\"d\"}\n\
495                   {\"kind\":\"retrieval\",\"ts\":\"2026-05-30T00:01:00Z\",\"device_id\":\"d\"}\n";
496        let events = parse_events_jsonl(raw).unwrap();
497        assert_eq!(events.len(), 2);
498    }
499
500    #[test]
501    fn manifest_lww_prefers_remote_when_newer() {
502        use crate::skill::manifest::{Content, Skill, SkillManifest, Visibility};
503        use crate::skill::types::Category;
504        let t1 = chrono::DateTime::from_timestamp(1_000, 0).unwrap();
505        let t2 = chrono::DateTime::from_timestamp(2_000, 0).unwrap();
506
507        let local = Skill {
508            manifest: SkillManifest {
509                name: "test".into(),
510                version: "1.0".into(),
511                publisher: "p".into(),
512                description: "d".into(),
513                category: Category::Context,
514                scope: Default::default(),
515                visibility: Visibility::default(),
516                origin: None,
517                origin_version: None,
518                origin_hash: None,
519                fleet: None,
520                team: None,
521                governance: None,
522                project: None,
523                provenance: Default::default(),
524                hosts: vec![],
525                content: Content {
526                    r#abstract: "a".into(),
527                    context: Some("c".into()),
528                    procedure: None,
529                    command: None,
530                    note: None,
531                },
532                requires: vec![],
533                tags: vec![],
534                triggers: vec![],
535                priority: Default::default(),
536                evolution_log: vec![],
537                transfer_chain: vec![],
538                mcp_requirements: vec![],
539                updated_at: t1,
540                requires_programs: vec![],
541            },
542            content_sha256: Some("hash".into()),
543            trust_level: Default::default(),
544            capabilities_declared: vec![],
545            publisher_signature: None,
546        };
547
548        let mut remote = local.clone();
549        remote.manifest.updated_at = t2;
550
551        let (winner, reason) = resolve_manifest_lww(local, remote, false);
552        assert_eq!(reason, "remote_newer");
553        assert_eq!(winner.manifest.updated_at, t2);
554    }
555
556    #[test]
557    fn manifest_lww_respects_force_local() {
558        use crate::skill::manifest::{Content, Skill, SkillManifest, Visibility};
559        use crate::skill::types::Category;
560        let t1 = chrono::DateTime::from_timestamp(1_000, 0).unwrap();
561        let t2 = chrono::DateTime::from_timestamp(2_000, 0).unwrap();
562
563        let local = Skill {
564            manifest: SkillManifest {
565                name: "test".into(),
566                version: "1.0".into(),
567                publisher: "p".into(),
568                description: "d".into(),
569                category: Category::Context,
570                scope: Default::default(),
571                visibility: Visibility::default(),
572                origin: None,
573                origin_version: None,
574                origin_hash: None,
575                fleet: None,
576                team: None,
577                governance: None,
578                project: None,
579                provenance: Default::default(),
580                hosts: vec![],
581                content: Content {
582                    r#abstract: "a".into(),
583                    context: Some("c".into()),
584                    procedure: None,
585                    command: None,
586                    note: None,
587                },
588                requires: vec![],
589                tags: vec![],
590                triggers: vec![],
591                priority: Default::default(),
592                evolution_log: vec![],
593                transfer_chain: vec![],
594                mcp_requirements: vec![],
595                updated_at: t1,
596                requires_programs: vec![],
597            },
598            content_sha256: Some("hash".into()),
599            trust_level: Default::default(),
600            capabilities_declared: vec![],
601            publisher_signature: None,
602        };
603
604        let mut remote = local.clone();
605        remote.manifest.updated_at = t2;
606
607        let (winner, reason) = resolve_manifest_lww(local.clone(), remote, true);
608        assert_eq!(reason, "force_local");
609        assert_eq!(winner.manifest.updated_at, t1);
610    }
611}