Skip to main content

zeph_memory/sqlite/
skills.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// Record usage of skills (UPSERT: increment count and update timestamp).
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the database operation fails.
71    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    /// Load all skill usage statistics.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the query fails.
92    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    /// Record a skill outcome event.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the insert fails.
117    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    /// Record outcomes for multiple skills in a single transaction.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if any insert fails (whole batch is rolled back).
147    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    /// Load metrics for a skill (latest version group).
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the query fails.
194    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    /// Load all skill outcome stats grouped by skill name.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if the query fails.
228    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    /// Save a new skill version and return its ID.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if the insert fails.
260    #[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    /// Load the active version for a skill.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the query fails.
293    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    /// Activate a specific version (deactivates others for the same skill).
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if the update fails.
314    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    /// Get the next version number for a skill.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if the query fails.
342    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    /// Get the latest auto-generated version's `created_at` for cooldown check.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the query fails.
357    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    /// Ensure a base (v1 manual) version exists for a skill. Idempotent.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the DB operation fails.
377    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    /// Load all versions for a skill, ordered by version number.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if the query fails.
403    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    /// Count auto-generated versions for a skill.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the query fails.
424    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    /// Delete oldest non-active auto versions exceeding max limit.
435    /// Returns the number of pruned versions.
436    ///
437    /// # Errors
438    ///
439    /// Returns an error if the delete fails.
440    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    /// Get the predecessor version for rollback.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error if the query fails.
467    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    /// Return the skill names for all currently active auto-generated versions.
494    ///
495    /// Used to check rollback eligibility at the start of each agent turn.
496    ///
497    /// # Errors
498    /// Returns [`MemoryError`] on `SQLite` query failure.
499    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}