1use super::SqliteStore;
2use crate::error::MemoryError;
3
4#[derive(Debug)]
5pub struct SkillUsageRow {
6 pub skill_name: String,
7 pub invocation_count: i64,
8 pub last_used_at: String,
9}
10
11#[derive(Debug)]
12pub struct SkillMetricsRow {
13 pub skill_name: String,
14 pub version_id: Option<i64>,
15 pub total: i64,
16 pub successes: i64,
17 pub failures: i64,
18}
19
20#[derive(Debug)]
21pub struct SkillVersionRow {
22 pub id: i64,
23 pub skill_name: String,
24 pub version: i64,
25 pub body: String,
26 pub description: String,
27 pub source: String,
28 pub is_active: bool,
29 pub success_count: i64,
30 pub failure_count: i64,
31 pub created_at: String,
32}
33
34type SkillVersionTuple = (
35 i64,
36 String,
37 i64,
38 String,
39 String,
40 String,
41 i64,
42 i64,
43 i64,
44 String,
45);
46
47fn skill_version_from_tuple(t: SkillVersionTuple) -> SkillVersionRow {
48 SkillVersionRow {
49 id: t.0,
50 skill_name: t.1,
51 version: t.2,
52 body: t.3,
53 description: t.4,
54 source: t.5,
55 is_active: t.6 != 0,
56 success_count: t.7,
57 failure_count: t.8,
58 created_at: t.9,
59 }
60}
61
62impl SqliteStore {
63 pub async fn record_skill_usage(&self, skill_names: &[&str]) -> Result<(), MemoryError> {
69 for name in skill_names {
70 sqlx::query(
71 "INSERT INTO skill_usage (skill_name, invocation_count, last_used_at) \
72 VALUES (?, 1, datetime('now')) \
73 ON CONFLICT(skill_name) DO UPDATE SET \
74 invocation_count = invocation_count + 1, \
75 last_used_at = datetime('now')",
76 )
77 .bind(name)
78 .execute(&self.pool)
79 .await?;
80 }
81 Ok(())
82 }
83
84 pub async fn load_skill_usage(&self) -> Result<Vec<SkillUsageRow>, MemoryError> {
90 let rows: Vec<(String, i64, String)> = sqlx::query_as(
91 "SELECT skill_name, invocation_count, last_used_at \
92 FROM skill_usage ORDER BY invocation_count DESC",
93 )
94 .fetch_all(&self.pool)
95 .await?;
96
97 Ok(rows
98 .into_iter()
99 .map(
100 |(skill_name, invocation_count, last_used_at)| SkillUsageRow {
101 skill_name,
102 invocation_count,
103 last_used_at,
104 },
105 )
106 .collect())
107 }
108
109 pub async fn record_skill_outcome(
115 &self,
116 skill_name: &str,
117 version_id: Option<i64>,
118 conversation_id: Option<crate::types::ConversationId>,
119 outcome: &str,
120 error_context: Option<&str>,
121 ) -> Result<(), MemoryError> {
122 sqlx::query(
123 "INSERT INTO skill_outcomes (skill_name, version_id, conversation_id, outcome, error_context) \
124 VALUES (?, ?, ?, ?, ?)",
125 )
126 .bind(skill_name)
127 .bind(version_id)
128 .bind(conversation_id)
129 .bind(outcome)
130 .bind(error_context)
131 .execute(&self.pool)
132 .await
133 ?;
134 Ok(())
135 }
136
137 pub async fn record_skill_outcomes_batch(
143 &self,
144 skill_names: &[String],
145 conversation_id: Option<crate::types::ConversationId>,
146 outcome: &str,
147 error_context: Option<&str>,
148 ) -> Result<(), MemoryError> {
149 let mut tx = self.pool.begin().await?;
150 for name in skill_names {
151 sqlx::query(
152 "INSERT INTO skill_outcomes \
153 (skill_name, version_id, conversation_id, outcome, error_context) \
154 VALUES (?, ?, ?, ?, ?)",
155 )
156 .bind(name)
157 .bind(None::<i64>)
158 .bind(conversation_id)
159 .bind(outcome)
160 .bind(error_context)
161 .execute(&mut *tx)
162 .await?;
163 }
164 tx.commit().await?;
165 Ok(())
166 }
167
168 pub async fn skill_metrics(
174 &self,
175 skill_name: &str,
176 ) -> Result<Option<SkillMetricsRow>, MemoryError> {
177 let row: Option<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
178 "SELECT skill_name, version_id, \
179 COUNT(*) as total, \
180 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
181 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
182 FROM skill_outcomes WHERE skill_name = ? \
183 GROUP BY skill_name, version_id \
184 ORDER BY version_id DESC LIMIT 1",
185 )
186 .bind(skill_name)
187 .fetch_optional(&self.pool)
188 .await?;
189
190 Ok(row.map(
191 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
192 skill_name,
193 version_id,
194 total,
195 successes,
196 failures,
197 },
198 ))
199 }
200
201 pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
207 let rows: Vec<(String, Option<i64>, i64, i64, i64)> = sqlx::query_as(
208 "SELECT skill_name, version_id, \
209 COUNT(*) as total, \
210 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
211 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
212 FROM skill_outcomes \
213 GROUP BY skill_name \
214 ORDER BY total DESC",
215 )
216 .fetch_all(&self.pool)
217 .await?;
218
219 Ok(rows
220 .into_iter()
221 .map(
222 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
223 skill_name,
224 version_id,
225 total,
226 successes,
227 failures,
228 },
229 )
230 .collect())
231 }
232
233 #[allow(clippy::too_many_arguments)]
239 pub async fn save_skill_version(
240 &self,
241 skill_name: &str,
242 version: i64,
243 body: &str,
244 description: &str,
245 source: &str,
246 error_context: Option<&str>,
247 predecessor_id: Option<i64>,
248 ) -> Result<i64, MemoryError> {
249 let row: (i64,) = sqlx::query_as(
250 "INSERT INTO skill_versions \
251 (skill_name, version, body, description, source, error_context, predecessor_id) \
252 VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
253 )
254 .bind(skill_name)
255 .bind(version)
256 .bind(body)
257 .bind(description)
258 .bind(source)
259 .bind(error_context)
260 .bind(predecessor_id)
261 .fetch_one(&self.pool)
262 .await?;
263 Ok(row.0)
264 }
265
266 pub async fn active_skill_version(
272 &self,
273 skill_name: &str,
274 ) -> Result<Option<SkillVersionRow>, MemoryError> {
275 let row: Option<SkillVersionTuple> = sqlx::query_as(
276 "SELECT id, skill_name, version, body, description, source, \
277 is_active, success_count, failure_count, created_at \
278 FROM skill_versions WHERE skill_name = ? AND is_active = 1 LIMIT 1",
279 )
280 .bind(skill_name)
281 .fetch_optional(&self.pool)
282 .await?;
283
284 Ok(row.map(skill_version_from_tuple))
285 }
286
287 pub async fn activate_skill_version(
293 &self,
294 skill_name: &str,
295 version_id: i64,
296 ) -> Result<(), MemoryError> {
297 let mut tx = self.pool.begin().await?;
298
299 sqlx::query(
300 "UPDATE skill_versions SET is_active = 0 WHERE skill_name = ? AND is_active = 1",
301 )
302 .bind(skill_name)
303 .execute(&mut *tx)
304 .await?;
305
306 sqlx::query("UPDATE skill_versions SET is_active = 1 WHERE id = ?")
307 .bind(version_id)
308 .execute(&mut *tx)
309 .await?;
310
311 tx.commit().await?;
312 Ok(())
313 }
314
315 pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
321 let row: (i64,) = sqlx::query_as(
322 "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?",
323 )
324 .bind(skill_name)
325 .fetch_one(&self.pool)
326 .await?;
327 Ok(row.0)
328 }
329
330 pub async fn last_improvement_time(
336 &self,
337 skill_name: &str,
338 ) -> Result<Option<String>, MemoryError> {
339 let row: Option<(String,)> = sqlx::query_as(
340 "SELECT created_at FROM skill_versions \
341 WHERE skill_name = ? AND source = 'auto' \
342 ORDER BY id DESC LIMIT 1",
343 )
344 .bind(skill_name)
345 .fetch_optional(&self.pool)
346 .await?;
347 Ok(row.map(|r| r.0))
348 }
349
350 pub async fn ensure_skill_version_exists(
356 &self,
357 skill_name: &str,
358 body: &str,
359 description: &str,
360 ) -> Result<(), MemoryError> {
361 let existing: Option<(i64,)> =
362 sqlx::query_as("SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1")
363 .bind(skill_name)
364 .fetch_optional(&self.pool)
365 .await?;
366
367 if existing.is_none() {
368 let id = self
369 .save_skill_version(skill_name, 1, body, description, "manual", None, None)
370 .await?;
371 self.activate_skill_version(skill_name, id).await?;
372 }
373 Ok(())
374 }
375
376 pub async fn load_skill_versions(
382 &self,
383 skill_name: &str,
384 ) -> Result<Vec<SkillVersionRow>, MemoryError> {
385 let rows: Vec<SkillVersionTuple> = sqlx::query_as(
386 "SELECT id, skill_name, version, body, description, source, \
387 is_active, success_count, failure_count, created_at \
388 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC",
389 )
390 .bind(skill_name)
391 .fetch_all(&self.pool)
392 .await?;
393
394 Ok(rows.into_iter().map(skill_version_from_tuple).collect())
395 }
396
397 pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
403 let row: (i64,) = sqlx::query_as(
404 "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'",
405 )
406 .bind(skill_name)
407 .fetch_one(&self.pool)
408 .await?;
409 Ok(row.0)
410 }
411
412 pub async fn prune_skill_versions(
419 &self,
420 skill_name: &str,
421 max_versions: u32,
422 ) -> Result<u32, MemoryError> {
423 let result = sqlx::query(
424 "DELETE FROM skill_versions WHERE id IN (\
425 SELECT id FROM skill_versions \
426 WHERE skill_name = ? AND source = 'auto' AND is_active = 0 \
427 ORDER BY id ASC \
428 LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
429 WHERE skill_name = ? AND source = 'auto') - ?)\
430 )",
431 )
432 .bind(skill_name)
433 .bind(skill_name)
434 .bind(max_versions)
435 .execute(&self.pool)
436 .await?;
437 Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
438 }
439
440 pub async fn predecessor_version(
446 &self,
447 version_id: i64,
448 ) -> Result<Option<SkillVersionRow>, MemoryError> {
449 let pred_id: Option<(Option<i64>,)> =
450 sqlx::query_as("SELECT predecessor_id FROM skill_versions WHERE id = ?")
451 .bind(version_id)
452 .fetch_optional(&self.pool)
453 .await?;
454
455 let Some((Some(pid),)) = pred_id else {
456 return Ok(None);
457 };
458
459 let row: Option<SkillVersionTuple> = sqlx::query_as(
460 "SELECT id, skill_name, version, body, description, source, \
461 is_active, success_count, failure_count, created_at \
462 FROM skill_versions WHERE id = ?",
463 )
464 .bind(pid)
465 .fetch_optional(&self.pool)
466 .await?;
467
468 Ok(row.map(skill_version_from_tuple))
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 async fn test_store() -> SqliteStore {
477 SqliteStore::new(":memory:").await.unwrap()
478 }
479
480 #[tokio::test]
481 async fn record_skill_usage_increments() {
482 let store = test_store().await;
483
484 store.record_skill_usage(&["git"]).await.unwrap();
485 store.record_skill_usage(&["git"]).await.unwrap();
486
487 let usage = store.load_skill_usage().await.unwrap();
488 assert_eq!(usage.len(), 1);
489 assert_eq!(usage[0].skill_name, "git");
490 assert_eq!(usage[0].invocation_count, 2);
491 }
492
493 #[tokio::test]
494 async fn load_skill_usage_returns_all() {
495 let store = test_store().await;
496
497 store.record_skill_usage(&["git", "docker"]).await.unwrap();
498 store.record_skill_usage(&["git"]).await.unwrap();
499
500 let usage = store.load_skill_usage().await.unwrap();
501 assert_eq!(usage.len(), 2);
502 assert_eq!(usage[0].skill_name, "git");
503 assert_eq!(usage[0].invocation_count, 2);
504 assert_eq!(usage[1].skill_name, "docker");
505 assert_eq!(usage[1].invocation_count, 1);
506 }
507
508 #[tokio::test]
509 async fn migration_005_creates_tables() {
510 let store = test_store().await;
511 let pool = store.pool();
512
513 let versions: (i64,) = sqlx::query_as(
514 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'",
515 )
516 .fetch_one(pool)
517 .await
518 .unwrap();
519 assert_eq!(versions.0, 1);
520
521 let outcomes: (i64,) = sqlx::query_as(
522 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'",
523 )
524 .fetch_one(pool)
525 .await
526 .unwrap();
527 assert_eq!(outcomes.0, 1);
528 }
529
530 #[tokio::test]
531 async fn record_skill_outcome_inserts() {
532 let store = test_store().await;
533
534 store
535 .record_skill_outcome(
536 "git",
537 None,
538 Some(crate::types::ConversationId(1)),
539 "success",
540 None,
541 )
542 .await
543 .unwrap();
544 store
545 .record_skill_outcome(
546 "git",
547 None,
548 Some(crate::types::ConversationId(1)),
549 "tool_failure",
550 Some("exit code 1"),
551 )
552 .await
553 .unwrap();
554
555 let metrics = store.skill_metrics("git").await.unwrap().unwrap();
556 assert_eq!(metrics.total, 2);
557 assert_eq!(metrics.successes, 1);
558 assert_eq!(metrics.failures, 1);
559 }
560
561 #[tokio::test]
562 async fn skill_metrics_none_for_unknown() {
563 let store = test_store().await;
564 let m = store.skill_metrics("nonexistent").await.unwrap();
565 assert!(m.is_none());
566 }
567
568 #[tokio::test]
569 async fn load_skill_outcome_stats_grouped() {
570 let store = test_store().await;
571
572 store
573 .record_skill_outcome("git", None, None, "success", None)
574 .await
575 .unwrap();
576 store
577 .record_skill_outcome("git", None, None, "tool_failure", None)
578 .await
579 .unwrap();
580 store
581 .record_skill_outcome("docker", None, None, "success", None)
582 .await
583 .unwrap();
584
585 let stats = store.load_skill_outcome_stats().await.unwrap();
586 assert_eq!(stats.len(), 2);
587 assert_eq!(stats[0].skill_name, "git");
588 assert_eq!(stats[0].total, 2);
589 assert_eq!(stats[1].skill_name, "docker");
590 assert_eq!(stats[1].total, 1);
591 }
592
593 #[tokio::test]
594 async fn save_and_load_skill_version() {
595 let store = test_store().await;
596
597 let id = store
598 .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
599 .await
600 .unwrap();
601 assert!(id > 0);
602
603 store.activate_skill_version("git", id).await.unwrap();
604
605 let active = store.active_skill_version("git").await.unwrap().unwrap();
606 assert_eq!(active.version, 1);
607 assert_eq!(active.body, "body v1");
608 assert!(active.is_active);
609 }
610
611 #[tokio::test]
612 async fn activate_deactivates_previous() {
613 let store = test_store().await;
614
615 let v1 = store
616 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
617 .await
618 .unwrap();
619 store.activate_skill_version("git", v1).await.unwrap();
620
621 let v2 = store
622 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
623 .await
624 .unwrap();
625 store.activate_skill_version("git", v2).await.unwrap();
626
627 let versions = store.load_skill_versions("git").await.unwrap();
628 assert_eq!(versions.len(), 2);
629 assert!(!versions[0].is_active);
630 assert!(versions[1].is_active);
631 }
632
633 #[tokio::test]
634 async fn next_skill_version_increments() {
635 let store = test_store().await;
636
637 let next = store.next_skill_version("git").await.unwrap();
638 assert_eq!(next, 1);
639
640 store
641 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
642 .await
643 .unwrap();
644 let next = store.next_skill_version("git").await.unwrap();
645 assert_eq!(next, 2);
646 }
647
648 #[tokio::test]
649 async fn last_improvement_time_returns_auto_only() {
650 let store = test_store().await;
651
652 store
653 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
654 .await
655 .unwrap();
656
657 let t = store.last_improvement_time("git").await.unwrap();
658 assert!(t.is_none());
659
660 store
661 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
662 .await
663 .unwrap();
664
665 let t = store.last_improvement_time("git").await.unwrap();
666 assert!(t.is_some());
667 }
668
669 #[tokio::test]
670 async fn ensure_skill_version_exists_idempotent() {
671 let store = test_store().await;
672
673 store
674 .ensure_skill_version_exists("git", "body", "Git helper")
675 .await
676 .unwrap();
677 store
678 .ensure_skill_version_exists("git", "body2", "Git helper 2")
679 .await
680 .unwrap();
681
682 let versions = store.load_skill_versions("git").await.unwrap();
683 assert_eq!(versions.len(), 1);
684 assert_eq!(versions[0].body, "body");
685 }
686
687 #[tokio::test]
688 async fn load_skill_versions_ordered() {
689 let store = test_store().await;
690
691 let v1 = store
692 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
693 .await
694 .unwrap();
695 store
696 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
697 .await
698 .unwrap();
699
700 let versions = store.load_skill_versions("git").await.unwrap();
701 assert_eq!(versions.len(), 2);
702 assert_eq!(versions[0].version, 1);
703 assert_eq!(versions[1].version, 2);
704 }
705
706 #[tokio::test]
707 async fn count_auto_versions_only() {
708 let store = test_store().await;
709
710 store
711 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
712 .await
713 .unwrap();
714 store
715 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
716 .await
717 .unwrap();
718 store
719 .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
720 .await
721 .unwrap();
722
723 let count = store.count_auto_versions("git").await.unwrap();
724 assert_eq!(count, 2);
725 }
726
727 #[tokio::test]
728 async fn prune_preserves_manual_and_active() {
729 let store = test_store().await;
730
731 let v1 = store
732 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
733 .await
734 .unwrap();
735 store.activate_skill_version("git", v1).await.unwrap();
736
737 for i in 2..=5 {
738 store
739 .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
740 .await
741 .unwrap();
742 }
743
744 let pruned = store.prune_skill_versions("git", 2).await.unwrap();
745 assert_eq!(pruned, 2);
746
747 let versions = store.load_skill_versions("git").await.unwrap();
748 assert!(versions.iter().any(|v| v.source == "manual"));
749 let auto_count = versions.iter().filter(|v| v.source == "auto").count();
750 assert_eq!(auto_count, 2);
751 }
752
753 #[tokio::test]
754 async fn predecessor_version_returns_parent() {
755 let store = test_store().await;
756
757 let v1 = store
758 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
759 .await
760 .unwrap();
761 let v2 = store
762 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
763 .await
764 .unwrap();
765
766 let pred = store.predecessor_version(v2).await.unwrap().unwrap();
767 assert_eq!(pred.id, v1);
768 assert_eq!(pred.version, 1);
769 }
770
771 #[tokio::test]
772 async fn predecessor_version_none_for_root() {
773 let store = test_store().await;
774
775 let v1 = store
776 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
777 .await
778 .unwrap();
779
780 let pred = store.predecessor_version(v1).await.unwrap();
781 assert!(pred.is_none());
782 }
783
784 #[tokio::test]
785 async fn active_skill_version_none_for_unknown() {
786 let store = test_store().await;
787 let active = store.active_skill_version("nonexistent").await.unwrap();
788 assert!(active.is_none());
789 }
790
791 #[tokio::test]
792 async fn load_skill_outcome_stats_empty() {
793 let store = test_store().await;
794 let stats = store.load_skill_outcome_stats().await.unwrap();
795 assert!(stats.is_empty());
796 }
797
798 #[tokio::test]
799 async fn load_skill_versions_empty() {
800 let store = test_store().await;
801 let versions = store.load_skill_versions("nonexistent").await.unwrap();
802 assert!(versions.is_empty());
803 }
804
805 #[tokio::test]
806 async fn count_auto_versions_zero_for_unknown() {
807 let store = test_store().await;
808 let count = store.count_auto_versions("nonexistent").await.unwrap();
809 assert_eq!(count, 0);
810 }
811
812 #[tokio::test]
813 async fn prune_nothing_when_below_limit() {
814 let store = test_store().await;
815
816 store
817 .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
818 .await
819 .unwrap();
820
821 let pruned = store.prune_skill_versions("git", 5).await.unwrap();
822 assert_eq!(pruned, 0);
823 }
824
825 #[tokio::test]
826 async fn record_skill_outcome_with_error_context() {
827 let store = test_store().await;
828
829 store
830 .record_skill_outcome(
831 "docker",
832 None,
833 Some(crate::types::ConversationId(1)),
834 "tool_failure",
835 Some("container not found"),
836 )
837 .await
838 .unwrap();
839
840 let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
841 assert_eq!(metrics.total, 1);
842 assert_eq!(metrics.failures, 1);
843 }
844
845 #[tokio::test]
846 async fn save_skill_version_with_error_context() {
847 let store = test_store().await;
848
849 let id = store
850 .save_skill_version(
851 "git",
852 1,
853 "improved body",
854 "Git helper",
855 "auto",
856 Some("exit code 128"),
857 None,
858 )
859 .await
860 .unwrap();
861 assert!(id > 0);
862 }
863}