agentmux_srv/migrations/
m0015_seed_starter_skills.rs1use std::sync::Arc;
28
29use crate::backend::skill_seed::{any_starter_skill_name_exists, seed_starter_skills};
30use crate::backend::storage::store::Store;
31use super::{Migration, MigrationContext, MigrationError, MigrationScope};
32
33pub struct M0015SeedStarterSkills;
34
35impl Migration for M0015SeedStarterSkills {
36 fn id(&self) -> &'static str { "0015_seed_starter_skills" }
37 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
38 fn description(&self) -> &'static str { "Seed the global Skills catalog with a curated starter set" }
39
40 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
41 if !ctx.channel_store_path.exists() {
42 return Ok(());
43 }
44 let mstore = Arc::new(
45 Store::open(&ctx.channel_store_path)
46 .map_err(|e| MigrationError(format!("seed_starter_skills: open mstore: {}", e)))?,
47 );
48 if any_starter_skill_name_exists(&mstore)
49 .map_err(|e| MigrationError(format!("seed_starter_skills: check existing: {}", e)))?
50 {
51 return Ok(());
52 }
53 seed_starter_skills(&mstore)
54 .map(|_| ())
55 .map_err(|e| MigrationError(format!("seed_starter_skills: {}", e)))
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 fn ctx_for(path: &std::path::Path) -> MigrationContext {
64 MigrationContext {
65 home: std::env::temp_dir(),
66 data_dir: std::env::temp_dir(),
67 shared_store_path: std::env::temp_dir().join("unused-store.db"),
68 channel_store_path: path.to_path_buf(),
69 }
70 }
71
72 #[test]
73 fn seeds_six_starter_skills_on_a_fresh_channel() {
74 let tmp = tempfile::NamedTempFile::new().unwrap();
75 Store::open(tmp.path()).unwrap();
78
79 M0015SeedStarterSkills.up(&ctx_for(tmp.path())).unwrap();
80
81 let mstore = Store::open(tmp.path()).unwrap();
82 assert_eq!(mstore.skill_list_global(&mstore).unwrap().len(), 6);
83 }
84
85 #[test]
86 fn once_marked_applied_a_full_catalog_deletion_is_never_resurrected() {
87 let tmp = tempfile::NamedTempFile::new().unwrap();
91 let mstore = Store::open(tmp.path()).unwrap();
92 let ctx = ctx_for(tmp.path());
93
94 M0015SeedStarterSkills.up(&ctx).unwrap();
95 mstore.migration_mark_applied("0015_seed_starter_skills", "channel", 0).unwrap();
96 assert_eq!(mstore.skill_list_global(&mstore).unwrap().len(), 6);
97
98 for item in mstore.skill_list_global(&mstore).unwrap() {
99 mstore.skill_delete(&mstore, &item.skill.id).unwrap();
100 }
101 assert!(mstore.skill_list_global(&mstore).unwrap().is_empty());
102
103 assert!(
107 mstore.migration_is_applied("0015_seed_starter_skills"),
108 "once applied, the tracking row must persist regardless of catalog contents"
109 );
110 }
111
112 #[test]
113 fn self_heals_when_starter_skills_already_exist_from_the_retired_startup_path() {
114 let tmp = tempfile::NamedTempFile::new().unwrap();
124 let mstore = Store::open(tmp.path()).unwrap();
125 let ctx = ctx_for(tmp.path());
126
127 let pre_existing = crate::backend::storage::skills::Skill {
128 id: "pre-existing-from-old-startup-path".to_string(),
129 name: "Systematic Debugging".to_string(),
130 trigger: "systematic-debugging".to_string(),
131 skill_type: "prompt".to_string(),
132 description: "Seeded by the retired startup path.".to_string(),
133 content: "n/a".to_string(),
134 is_global: true,
135 created_at: 0,
136 updated_at: 0,
137 };
138 mstore.skill_upsert_unique_global(&pre_existing).unwrap();
139
140 M0015SeedStarterSkills.up(&ctx).unwrap();
141
142 let after = mstore.skill_list_global(&mstore).unwrap();
143 assert_eq!(
144 after.len(),
145 1,
146 "up() must skip seeding entirely on a name collision, not insert the other 5"
147 );
148 assert_eq!(after[0].skill.id, "pre-existing-from-old-startup-path");
149 }
150}