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