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 mur_home.join("fleets").join(fleet).join("events.jsonl");
98 }
99 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 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 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
154pub 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
167pub 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
196pub struct RunRecord<'a> {
198 pub success: bool,
200 pub duration_ms: Option<u64>,
201 pub exit_code: Option<i32>,
202 pub stderr: Option<&'a str>,
205 pub failed_step: Option<String>,
207 pub trigger: &'a str,
209 pub env_class_override: Option<&'a str>,
212}
213
214pub 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
249pub 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 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 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 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 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 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 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 #[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); }
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}