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