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