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