Skip to main content

agentmux_srv/migrations/
m0015_seed_starter_skills.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Seed the global Skills catalog (`db_skills`, per-channel) with a curated
5//! starter set on fresh install.
6//!
7//! Runs exactly once per channel, tracked in that channel's `db_migrations`
8//! table — NOT gated on "is the catalog currently empty." The prior
9//! `seed_starter_skills_if_empty` gate (removed) couldn't distinguish "never
10//! seeded" from "seeded then the user deleted every starter skill on
11//! purpose," so a normal srv restart after a full deletion silently
12//! resurrected the defaults (reagent P2, PR #2141 round 2 — confirmed by
13//! `skill_seed.rs`'s own former test). The migration framework's
14//! once-ever-per-channel tracking fixes this at the root: once
15//! `0015_seed_starter_skills` is marked applied, it never runs again for
16//! that channel regardless of what the user does to the catalog afterward.
17//!
18//! Self-heals against installs that already ran the retired pre-migration
19//! startup-seed path (#2141) before this migration existed — every such
20//! channel already has the 6 starter-skill names present, which would
21//! collide with `skill_upsert_unique_global`'s name-uniqueness check and
22//! permanently fail this migration on every boot if seeding were attempted
23//! unconditionally (reagent P1, PR #2144). `up()` checks for an existing
24//! name collision first and treats it as "already seeded" rather than
25//! inserting.
26
27use 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        // Create the channel store up front — `up()` no-ops when the path
76        // doesn't exist yet (mirrors m0007's guard).
77        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        // This is the actual fix: the migration framework's db_migrations
88        // tracking (not skill_seed's own logic) is what must prevent
89        // reseeding — reproduce that gate here rather than trusting it.
90        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        // The real runner (runner.rs) never calls `up()` again once
104        // `migration_is_applied` is true — assert that precondition holds,
105        // matching the actual gate every real boot goes through.
106        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        // Reagent P1 (PR #2144): every channel that already booted once
115        // under the retired pre-migration startup-seed path (#2141) already
116        // has the 6 starter-skill names present. Without this self-heal,
117        // `up()` would call seed_starter_skills unconditionally, collide on
118        // skill_upsert_unique_global's name-uniqueness check on the very
119        // first insert, return Err, never get marked applied, and retry-fail
120        // on every subsequent boot. Simulate that pre-existing state
121        // directly (not via the removed startup path) and confirm `up()`
122        // succeeds as a no-op instead of erroring.
123        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}