1use super::SqliteStore;
5use crate::error::MemoryError;
6
7#[derive(Debug)]
8pub struct SkillUsageRow {
9 pub skill_name: String,
10 pub invocation_count: i64,
11 pub last_used_at: String,
12}
13
14#[derive(Debug)]
15pub struct SkillMetricsRow {
16 pub skill_name: String,
17 pub version_id: Option<i64>,
18 pub total: i64,
19 pub successes: i64,
20 pub failures: i64,
21}
22
23#[derive(Debug)]
24pub struct SkillVersionRow {
25 pub id: i64,
26 pub skill_name: String,
27 pub version: i64,
28 pub body: String,
29 pub description: String,
30 pub source: String,
31 pub is_active: bool,
32 pub success_count: i64,
33 pub failure_count: i64,
34 pub created_at: String,
35}
36
37type SkillVersionTuple = (
38 i64,
39 String,
40 i64,
41 String,
42 String,
43 String,
44 i64,
45 i64,
46 i64,
47 String,
48);
49
50fn skill_version_from_tuple(t: SkillVersionTuple) -> SkillVersionRow {
51 SkillVersionRow {
52 id: t.0,
53 skill_name: t.1,
54 version: t.2,
55 body: t.3,
56 description: t.4,
57 source: t.5,
58 is_active: t.6 != 0,
59 success_count: t.7,
60 failure_count: t.8,
61 created_at: t.9,
62 }
63}
64
65impl SqliteStore {
66 pub async fn record_skill_usage(&self, skill_names: &[&str]) -> Result<(), MemoryError> {
72 for name in skill_names {
73 sqlx::query(
74 "INSERT INTO skill_usage (skill_name, invocation_count, last_used_at) \
75 VALUES (?, 1, datetime('now')) \
76 ON CONFLICT(skill_name) DO UPDATE SET \
77 invocation_count = invocation_count + 1, \
78 last_used_at = datetime('now')",
79 )
80 .bind(name)
81 .execute(&self.pool)
82 .await?;
83 }
84 Ok(())
85 }
86
87 pub async fn load_skill_usage(&self) -> Result<Vec<SkillUsageRow>, MemoryError> {
93 let rows: Vec<(String, i64, String)> = sqlx::query_as(
94 "SELECT skill_name, invocation_count, last_used_at \
95 FROM skill_usage ORDER BY invocation_count DESC",
96 )
97 .fetch_all(&self.pool)
98 .await?;
99
100 Ok(rows
101 .into_iter()
102 .map(
103 |(skill_name, invocation_count, last_used_at)| SkillUsageRow {
104 skill_name,
105 invocation_count,
106 last_used_at,
107 },
108 )
109 .collect())
110 }
111
112 pub async fn record_skill_outcome(
118 &self,
119 skill_name: &str,
120 version_id: Option<i64>,
121 conversation_id: Option<crate::types::ConversationId>,
122 outcome: &str,
123 error_context: Option<&str>,
124 outcome_detail: Option<&str>,
125 ) -> Result<(), MemoryError> {
126 sqlx::query(
127 "INSERT INTO skill_outcomes \
128 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
129 VALUES (?, ?, ?, ?, ?, ?)",
130 )
131 .bind(skill_name)
132 .bind(version_id)
133 .bind(conversation_id)
134 .bind(outcome)
135 .bind(error_context)
136 .bind(outcome_detail)
137 .execute(&self.pool)
138 .await?;
139 Ok(())
140 }
141
142 pub async fn record_skill_outcomes_batch(
148 &self,
149 skill_names: &[String],
150 conversation_id: Option<crate::types::ConversationId>,
151 outcome: &str,
152 error_context: Option<&str>,
153 outcome_detail: Option<&str>,
154 ) -> Result<(), MemoryError> {
155 let mut tx = self.pool.begin().await?;
156
157 let mut version_map: std::collections::HashMap<String, Option<i64>> =
158 std::collections::HashMap::new();
159 for name in skill_names {
160 let vid: Option<(i64,)> = sqlx::query_as(
161 "SELECT id FROM skill_versions WHERE skill_name = ? AND is_active = 1",
162 )
163 .bind(name)
164 .fetch_optional(&mut *tx)
165 .await?;
166 version_map.insert(name.clone(), vid.map(|r| r.0));
167 }
168
169 for name in skill_names {
170 let version_id = version_map.get(name.as_str()).copied().flatten();
171 sqlx::query(
172 "INSERT INTO skill_outcomes \
173 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
174 VALUES (?, ?, ?, ?, ?, ?)",
175 )
176 .bind(name)
177 .bind(version_id)
178 .bind(conversation_id)
179 .bind(outcome)
180 .bind(error_context)
181 .bind(outcome_detail)
182 .execute(&mut *tx)
183 .await?;
184 }
185 tx.commit().await?;
186 Ok(())
187 }
188
189 pub async fn skill_metrics(
195 &self,
196 skill_name: &str,
197 ) -> Result<Option<SkillMetricsRow>, MemoryError> {
198 let row: Option<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
199 "SELECT skill_name, version_id, \
200 COUNT(*) as total, \
201 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
202 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
203 FROM skill_outcomes WHERE skill_name = ? \
204 AND outcome NOT IN ('user_approval', 'user_rejection') \
205 GROUP BY skill_name, version_id \
206 ORDER BY version_id DESC LIMIT 1",
207 )
208 .bind(skill_name)
209 .fetch_optional(&self.pool)
210 .await?;
211
212 Ok(row.map(
213 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
214 skill_name,
215 version_id,
216 total,
217 successes,
218 failures,
219 },
220 ))
221 }
222
223 pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
229 let rows: Vec<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
230 "SELECT skill_name, version_id, \
231 COUNT(*) as total, \
232 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
233 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
234 FROM skill_outcomes \
235 GROUP BY skill_name \
236 ORDER BY total DESC",
237 )
238 .fetch_all(&self.pool)
239 .await?;
240
241 Ok(rows
242 .into_iter()
243 .map(
244 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
245 skill_name,
246 version_id,
247 total,
248 successes,
249 failures,
250 },
251 )
252 .collect())
253 }
254
255 #[allow(clippy::too_many_arguments)]
261 pub async fn save_skill_version(
262 &self,
263 skill_name: &str,
264 version: i64,
265 body: &str,
266 description: &str,
267 source: &str,
268 error_context: Option<&str>,
269 predecessor_id: Option<i64>,
270 ) -> Result<i64, MemoryError> {
271 let row: (i64,) = sqlx::query_as(
272 "INSERT INTO skill_versions \
273 (skill_name, version, body, description, source, error_context, predecessor_id) \
274 VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
275 )
276 .bind(skill_name)
277 .bind(version)
278 .bind(body)
279 .bind(description)
280 .bind(source)
281 .bind(error_context)
282 .bind(predecessor_id)
283 .fetch_one(&self.pool)
284 .await?;
285 Ok(row.0)
286 }
287
288 pub async fn active_skill_version(
294 &self,
295 skill_name: &str,
296 ) -> Result<Option<SkillVersionRow>, MemoryError> {
297 let row: Option<SkillVersionTuple> = sqlx::query_as(
298 "SELECT id, skill_name, version, body, description, source, \
299 is_active, success_count, failure_count, created_at \
300 FROM skill_versions WHERE skill_name = ? AND is_active = 1 LIMIT 1",
301 )
302 .bind(skill_name)
303 .fetch_optional(&self.pool)
304 .await?;
305
306 Ok(row.map(skill_version_from_tuple))
307 }
308
309 pub async fn activate_skill_version(
315 &self,
316 skill_name: &str,
317 version_id: i64,
318 ) -> Result<(), MemoryError> {
319 let mut tx = self.pool.begin().await?;
320
321 sqlx::query(
322 "UPDATE skill_versions SET is_active = 0 WHERE skill_name = ? AND is_active = 1",
323 )
324 .bind(skill_name)
325 .execute(&mut *tx)
326 .await?;
327
328 sqlx::query("UPDATE skill_versions SET is_active = 1 WHERE id = ?")
329 .bind(version_id)
330 .execute(&mut *tx)
331 .await?;
332
333 tx.commit().await?;
334 Ok(())
335 }
336
337 pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
343 let row: (i64,) = sqlx::query_as(
344 "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?",
345 )
346 .bind(skill_name)
347 .fetch_one(&self.pool)
348 .await?;
349 Ok(row.0)
350 }
351
352 pub async fn last_improvement_time(
358 &self,
359 skill_name: &str,
360 ) -> Result<Option<String>, MemoryError> {
361 let row: Option<(String,)> = sqlx::query_as(
362 "SELECT created_at FROM skill_versions \
363 WHERE skill_name = ? AND source = 'auto' \
364 ORDER BY id DESC LIMIT 1",
365 )
366 .bind(skill_name)
367 .fetch_optional(&self.pool)
368 .await?;
369 Ok(row.map(|r| r.0))
370 }
371
372 pub async fn ensure_skill_version_exists(
378 &self,
379 skill_name: &str,
380 body: &str,
381 description: &str,
382 ) -> Result<(), MemoryError> {
383 let existing: Option<(i64,)> =
384 sqlx::query_as("SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1")
385 .bind(skill_name)
386 .fetch_optional(&self.pool)
387 .await?;
388
389 if existing.is_none() {
390 let id = self
391 .save_skill_version(skill_name, 1, body, description, "manual", None, None)
392 .await?;
393 self.activate_skill_version(skill_name, id).await?;
394 }
395 Ok(())
396 }
397
398 pub async fn load_skill_versions(
404 &self,
405 skill_name: &str,
406 ) -> Result<Vec<SkillVersionRow>, MemoryError> {
407 let rows: Vec<SkillVersionTuple> = sqlx::query_as(
408 "SELECT id, skill_name, version, body, description, source, \
409 is_active, success_count, failure_count, created_at \
410 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC",
411 )
412 .bind(skill_name)
413 .fetch_all(&self.pool)
414 .await?;
415
416 Ok(rows.into_iter().map(skill_version_from_tuple).collect())
417 }
418
419 pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
425 let row: (i64,) = sqlx::query_as(
426 "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'",
427 )
428 .bind(skill_name)
429 .fetch_one(&self.pool)
430 .await?;
431 Ok(row.0)
432 }
433
434 pub async fn prune_skill_versions(
441 &self,
442 skill_name: &str,
443 max_versions: u32,
444 ) -> Result<u32, MemoryError> {
445 let result = sqlx::query(
446 "DELETE FROM skill_versions WHERE id IN (\
447 SELECT id FROM skill_versions \
448 WHERE skill_name = ? AND source = 'auto' AND is_active = 0 \
449 ORDER BY id ASC \
450 LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
451 WHERE skill_name = ? AND source = 'auto') - ?)\
452 )",
453 )
454 .bind(skill_name)
455 .bind(skill_name)
456 .bind(max_versions)
457 .execute(&self.pool)
458 .await?;
459 Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
460 }
461
462 pub async fn predecessor_version(
468 &self,
469 version_id: i64,
470 ) -> Result<Option<SkillVersionRow>, MemoryError> {
471 let pred_id: Option<(Option<i64>,)> =
472 sqlx::query_as("SELECT predecessor_id FROM skill_versions WHERE id = ?")
473 .bind(version_id)
474 .fetch_optional(&self.pool)
475 .await?;
476
477 let Some((Some(pid),)) = pred_id else {
478 return Ok(None);
479 };
480
481 let row: Option<SkillVersionTuple> = sqlx::query_as(
482 "SELECT id, skill_name, version, body, description, source, \
483 is_active, success_count, failure_count, created_at \
484 FROM skill_versions WHERE id = ?",
485 )
486 .bind(pid)
487 .fetch_optional(&self.pool)
488 .await?;
489
490 Ok(row.map(skill_version_from_tuple))
491 }
492
493 pub async fn list_active_auto_versions(&self) -> Result<Vec<String>, MemoryError> {
500 let rows: Vec<(String,)> = sqlx::query_as(
501 "SELECT skill_name FROM skill_versions WHERE is_active = 1 AND source = 'auto'",
502 )
503 .fetch_all(&self.pool)
504 .await?;
505 Ok(rows.into_iter().map(|(name,)| name).collect())
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 async fn test_store() -> SqliteStore {
514 SqliteStore::new(":memory:").await.unwrap()
515 }
516
517 #[tokio::test]
518 async fn record_skill_usage_increments() {
519 let store = test_store().await;
520
521 store.record_skill_usage(&["git"]).await.unwrap();
522 store.record_skill_usage(&["git"]).await.unwrap();
523
524 let usage = store.load_skill_usage().await.unwrap();
525 assert_eq!(usage.len(), 1);
526 assert_eq!(usage[0].skill_name, "git");
527 assert_eq!(usage[0].invocation_count, 2);
528 }
529
530 #[tokio::test]
531 async fn load_skill_usage_returns_all() {
532 let store = test_store().await;
533
534 store.record_skill_usage(&["git", "docker"]).await.unwrap();
535 store.record_skill_usage(&["git"]).await.unwrap();
536
537 let usage = store.load_skill_usage().await.unwrap();
538 assert_eq!(usage.len(), 2);
539 assert_eq!(usage[0].skill_name, "git");
540 assert_eq!(usage[0].invocation_count, 2);
541 assert_eq!(usage[1].skill_name, "docker");
542 assert_eq!(usage[1].invocation_count, 1);
543 }
544
545 #[tokio::test]
546 async fn migration_005_creates_tables() {
547 let store = test_store().await;
548 let pool = store.pool();
549
550 let versions: (i64,) = sqlx::query_as(
551 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'",
552 )
553 .fetch_one(pool)
554 .await
555 .unwrap();
556 assert_eq!(versions.0, 1);
557
558 let outcomes: (i64,) = sqlx::query_as(
559 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'",
560 )
561 .fetch_one(pool)
562 .await
563 .unwrap();
564 assert_eq!(outcomes.0, 1);
565 }
566
567 #[tokio::test]
568 async fn record_skill_outcome_inserts() {
569 let store = test_store().await;
570
571 store
572 .record_skill_outcome(
573 "git",
574 None,
575 Some(crate::types::ConversationId(1)),
576 "success",
577 None,
578 None,
579 )
580 .await
581 .unwrap();
582 store
583 .record_skill_outcome(
584 "git",
585 None,
586 Some(crate::types::ConversationId(1)),
587 "tool_failure",
588 Some("exit code 1"),
589 None,
590 )
591 .await
592 .unwrap();
593
594 let metrics = store.skill_metrics("git").await.unwrap().unwrap();
595 assert_eq!(metrics.total, 2);
596 assert_eq!(metrics.successes, 1);
597 assert_eq!(metrics.failures, 1);
598 }
599
600 #[tokio::test]
601 async fn skill_metrics_none_for_unknown() {
602 let store = test_store().await;
603 let m = store.skill_metrics("nonexistent").await.unwrap();
604 assert!(m.is_none());
605 }
606
607 #[tokio::test]
608 async fn load_skill_outcome_stats_grouped() {
609 let store = test_store().await;
610
611 store
612 .record_skill_outcome("git", None, None, "success", None, None)
613 .await
614 .unwrap();
615 store
616 .record_skill_outcome("git", None, None, "tool_failure", None, None)
617 .await
618 .unwrap();
619 store
620 .record_skill_outcome("docker", None, None, "success", None, None)
621 .await
622 .unwrap();
623
624 let stats = store.load_skill_outcome_stats().await.unwrap();
625 assert_eq!(stats.len(), 2);
626 assert_eq!(stats[0].skill_name, "git");
627 assert_eq!(stats[0].total, 2);
628 assert_eq!(stats[1].skill_name, "docker");
629 assert_eq!(stats[1].total, 1);
630 }
631
632 #[tokio::test]
633 async fn save_and_load_skill_version() {
634 let store = test_store().await;
635
636 let id = store
637 .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
638 .await
639 .unwrap();
640 assert!(id > 0);
641
642 store.activate_skill_version("git", id).await.unwrap();
643
644 let active = store.active_skill_version("git").await.unwrap().unwrap();
645 assert_eq!(active.version, 1);
646 assert_eq!(active.body, "body v1");
647 assert!(active.is_active);
648 }
649
650 #[tokio::test]
651 async fn activate_deactivates_previous() {
652 let store = test_store().await;
653
654 let v1 = store
655 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
656 .await
657 .unwrap();
658 store.activate_skill_version("git", v1).await.unwrap();
659
660 let v2 = store
661 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
662 .await
663 .unwrap();
664 store.activate_skill_version("git", v2).await.unwrap();
665
666 let versions = store.load_skill_versions("git").await.unwrap();
667 assert_eq!(versions.len(), 2);
668 assert!(!versions[0].is_active);
669 assert!(versions[1].is_active);
670 }
671
672 #[tokio::test]
673 async fn next_skill_version_increments() {
674 let store = test_store().await;
675
676 let next = store.next_skill_version("git").await.unwrap();
677 assert_eq!(next, 1);
678
679 store
680 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
681 .await
682 .unwrap();
683 let next = store.next_skill_version("git").await.unwrap();
684 assert_eq!(next, 2);
685 }
686
687 #[tokio::test]
688 async fn last_improvement_time_returns_auto_only() {
689 let store = test_store().await;
690
691 store
692 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
693 .await
694 .unwrap();
695
696 let t = store.last_improvement_time("git").await.unwrap();
697 assert!(t.is_none());
698
699 store
700 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
701 .await
702 .unwrap();
703
704 let t = store.last_improvement_time("git").await.unwrap();
705 assert!(t.is_some());
706 }
707
708 #[tokio::test]
709 async fn ensure_skill_version_exists_idempotent() {
710 let store = test_store().await;
711
712 store
713 .ensure_skill_version_exists("git", "body", "Git helper")
714 .await
715 .unwrap();
716 store
717 .ensure_skill_version_exists("git", "body2", "Git helper 2")
718 .await
719 .unwrap();
720
721 let versions = store.load_skill_versions("git").await.unwrap();
722 assert_eq!(versions.len(), 1);
723 assert_eq!(versions[0].body, "body");
724 }
725
726 #[tokio::test]
727 async fn load_skill_versions_ordered() {
728 let store = test_store().await;
729
730 let v1 = store
731 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
732 .await
733 .unwrap();
734 store
735 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
736 .await
737 .unwrap();
738
739 let versions = store.load_skill_versions("git").await.unwrap();
740 assert_eq!(versions.len(), 2);
741 assert_eq!(versions[0].version, 1);
742 assert_eq!(versions[1].version, 2);
743 }
744
745 #[tokio::test]
746 async fn count_auto_versions_only() {
747 let store = test_store().await;
748
749 store
750 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
751 .await
752 .unwrap();
753 store
754 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
755 .await
756 .unwrap();
757 store
758 .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
759 .await
760 .unwrap();
761
762 let count = store.count_auto_versions("git").await.unwrap();
763 assert_eq!(count, 2);
764 }
765
766 #[tokio::test]
767 async fn prune_preserves_manual_and_active() {
768 let store = test_store().await;
769
770 let v1 = store
771 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
772 .await
773 .unwrap();
774 store.activate_skill_version("git", v1).await.unwrap();
775
776 for i in 2..=5 {
777 store
778 .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
779 .await
780 .unwrap();
781 }
782
783 let pruned = store.prune_skill_versions("git", 2).await.unwrap();
784 assert_eq!(pruned, 2);
785
786 let versions = store.load_skill_versions("git").await.unwrap();
787 assert!(versions.iter().any(|v| v.source == "manual"));
788 let auto_count = versions.iter().filter(|v| v.source == "auto").count();
789 assert_eq!(auto_count, 2);
790 }
791
792 #[tokio::test]
793 async fn predecessor_version_returns_parent() {
794 let store = test_store().await;
795
796 let v1 = store
797 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
798 .await
799 .unwrap();
800 let v2 = store
801 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
802 .await
803 .unwrap();
804
805 let pred = store.predecessor_version(v2).await.unwrap().unwrap();
806 assert_eq!(pred.id, v1);
807 assert_eq!(pred.version, 1);
808 }
809
810 #[tokio::test]
811 async fn predecessor_version_none_for_root() {
812 let store = test_store().await;
813
814 let v1 = store
815 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
816 .await
817 .unwrap();
818
819 let pred = store.predecessor_version(v1).await.unwrap();
820 assert!(pred.is_none());
821 }
822
823 #[tokio::test]
824 async fn active_skill_version_none_for_unknown() {
825 let store = test_store().await;
826 let active = store.active_skill_version("nonexistent").await.unwrap();
827 assert!(active.is_none());
828 }
829
830 #[tokio::test]
831 async fn load_skill_outcome_stats_empty() {
832 let store = test_store().await;
833 let stats = store.load_skill_outcome_stats().await.unwrap();
834 assert!(stats.is_empty());
835 }
836
837 #[tokio::test]
838 async fn load_skill_versions_empty() {
839 let store = test_store().await;
840 let versions = store.load_skill_versions("nonexistent").await.unwrap();
841 assert!(versions.is_empty());
842 }
843
844 #[tokio::test]
845 async fn count_auto_versions_zero_for_unknown() {
846 let store = test_store().await;
847 let count = store.count_auto_versions("nonexistent").await.unwrap();
848 assert_eq!(count, 0);
849 }
850
851 #[tokio::test]
852 async fn prune_nothing_when_below_limit() {
853 let store = test_store().await;
854
855 store
856 .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
857 .await
858 .unwrap();
859
860 let pruned = store.prune_skill_versions("git", 5).await.unwrap();
861 assert_eq!(pruned, 0);
862 }
863
864 #[tokio::test]
865 async fn record_skill_outcome_with_error_context() {
866 let store = test_store().await;
867
868 store
869 .record_skill_outcome(
870 "docker",
871 None,
872 Some(crate::types::ConversationId(1)),
873 "tool_failure",
874 Some("container not found"),
875 None,
876 )
877 .await
878 .unwrap();
879
880 let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
881 assert_eq!(metrics.total, 1);
882 assert_eq!(metrics.failures, 1);
883 }
884
885 #[tokio::test]
886 async fn save_skill_version_with_error_context() {
887 let store = test_store().await;
888
889 let id = store
890 .save_skill_version(
891 "git",
892 1,
893 "improved body",
894 "Git helper",
895 "auto",
896 Some("exit code 128"),
897 None,
898 )
899 .await
900 .unwrap();
901 assert!(id > 0);
902 }
903
904 #[tokio::test]
905 async fn record_skill_outcomes_batch_resolves_version_id() {
906 let store = test_store().await;
907
908 let vid = store
909 .save_skill_version("git", 1, "body", "desc", "manual", None, None)
910 .await
911 .unwrap();
912 store.activate_skill_version("git", vid).await.unwrap();
913
914 store
915 .record_skill_outcomes_batch(
916 &["git".to_string()],
917 None,
918 "tool_failure",
919 Some("exit code 1"),
920 Some("exit_nonzero"),
921 )
922 .await
923 .unwrap();
924
925 let pool = store.pool();
926 let row: (Option<i64>, Option<String>) =
927 sqlx::query_as("SELECT version_id, outcome_detail FROM skill_outcomes WHERE skill_name = 'git' LIMIT 1")
928 .fetch_one(pool)
929 .await
930 .unwrap();
931 assert_eq!(
932 row.0,
933 Some(vid),
934 "version_id should be resolved to active version"
935 );
936 assert_eq!(row.1.as_deref(), Some("exit_nonzero"));
937 }
938
939 #[tokio::test]
940 async fn record_skill_outcome_stores_outcome_detail() {
941 let store = test_store().await;
942
943 store
944 .record_skill_outcome("docker", None, None, "tool_failure", None, Some("timeout"))
945 .await
946 .unwrap();
947
948 let pool = store.pool();
949 let row: (Option<String>,) = sqlx::query_as(
950 "SELECT outcome_detail FROM skill_outcomes WHERE skill_name = 'docker' LIMIT 1",
951 )
952 .fetch_one(pool)
953 .await
954 .unwrap();
955 assert_eq!(row.0.as_deref(), Some("timeout"));
956 }
957}