1use 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 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 #[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 #[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 #[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 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 if let Some(fleet) = skill_name.strip_prefix("fleet:") {
97 return crate::paths::fleet_state_dir(mur_home, fleet).join("events.jsonl");
99 }
100 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 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 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
155pub 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
168pub 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
197pub struct RunRecord<'a> {
199 pub success: bool,
201 pub duration_ms: Option<u64>,
202 pub exit_code: Option<i32>,
203 pub stderr: Option<&'a str>,
206 pub failed_step: Option<String>,
208 pub trigger: &'a str,
210 pub env_class_override: Option<&'a str>,
213}
214
215pub 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
250pub 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 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 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 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 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 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 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 #[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); }
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}