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 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 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 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
141pub 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
154pub 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
183pub struct RunRecord<'a> {
185 pub success: bool,
187 pub duration_ms: Option<u64>,
188 pub exit_code: Option<i32>,
189 pub stderr: Option<&'a str>,
192 pub failed_step: Option<String>,
194 pub trigger: &'a str,
196 pub env_class_override: Option<&'a str>,
199}
200
201pub 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
236pub 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 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 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 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 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 #[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); }
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}