Skip to main content

agentmux_srv/backend/
skill_seed.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Starter Skills catalog seed: preloads a small set of curated global
5//! skills (`db_skills`, the v1 standalone catalog) on fresh install.
6//!
7//! The actual "run exactly once, ever" gating lives in
8//! `migrations::m0015_seed_starter_skills`, tracked in the channel's
9//! `db_migrations` table — NOT in this module. An earlier version gated on
10//! "is the catalog currently empty," which couldn't distinguish "never
11//! seeded" from "seeded then the user deleted every starter skill on
12//! purpose," silently resurrecting the defaults on the next restart after a
13//! full deletion (reagent P2, PR #2141 round 2). This module now exposes
14//! only the pure insert logic; the migration owns once-ever invocation.
15//!
16//! Distinct from `agent_seed.rs`, which seeds legacy per-agent skills into
17//! `db_agent_skills` and re-seeds on every manifest version bump.
18//!
19//! **Ids are deterministic, not randomly minted** (Phase 1 of
20//! `SPEC_DURABLE_BINDINGS_2026_09_10.md`). Every store that seeds "Systematic
21//! Debugging" produces the exact same row id for it, derived from the
22//! manifest entry's `trigger` — the stable slash-command slug, not `name`
23//! (display text; changing it must not change identity) and not `content`
24//! (edited over time for typos/clarity; a wording fix is not a new skill).
25//! This is what a later cross-store reconciliation needs to recognize "these
26//! are the same seeded skill" without a name-matching heuristic that a
27//! user-created private skill sharing that name could collide with — see
28//! `starter_skill_id`'s own doc comment for why a bare name match is unsafe
29//! in general. Existing installs, already seeded under this migration before
30//! this change shipped, keep whatever random id they already have; this only
31//! changes what a FUTURE seed produces.
32
33use std::sync::Arc;
34
35use serde::Deserialize;
36use uuid::Uuid;
37
38use super::storage::skills::Skill;
39use super::storage::store::Store;
40use super::storage::StoreError;
41
42/// One entry in the embedded starter-skills manifest. Field names mirror
43/// `Skill` minus the store-generated `id`/`is_global`/timestamps.
44#[derive(Debug, Deserialize)]
45struct StarterSkill {
46    name: String,
47    trigger: String,
48    skill_type: String,
49    description: String,
50    content: String,
51}
52
53/// The embedded starter-skills manifest JSON. Content is authored
54/// externally and must not be edited here — see
55/// `crates/srv/src/config/starter-skills.json`.
56const STARTER_SKILLS_JSON: &str = include_str!("../config/starter-skills.json");
57
58/// Deterministic row id for a starter skill, derived from its `trigger`.
59///
60/// A bare name match is not a safe way to recognize "the same skill" across
61/// stores in general — `managed_upsert_unique` enforces name uniqueness only
62/// WITHIN one owner (a bundle, or the global tier), so two different bundles
63/// can each legitimately hold a private skill called the same thing with
64/// different content (`SPEC_DURABLE_BINDINGS_2026_09_10.md` §5.3). Collapsing
65/// on name alone would discard one payload and cross-link edits between two
66/// owners that never agreed to share a row.
67///
68/// The ambiguity does not apply to a SEEDED global row specifically: it is
69/// owned by nobody, so two stores minting one under the same key are, by
70/// construction, minting the same resource. Giving that case a stable id up
71/// front means a later cross-store reconciliation does not need the
72/// name-matching heuristic at all for anything this function produced — only
73/// for genuinely user-created rows, which is the case that heuristic was
74/// actually built for.
75///
76/// `trigger`, not `name` or `content`: `name` is display text a maintainer
77/// could reword without changing what skill this is, and `content` gets
78/// wording fixes over time — neither should mint a new identity. `trigger`
79/// is the stable slash-command slug already surfaced to the user
80/// (`docs/CLAUDE.md`'s own "Available Skills" list uses it as the anchor),
81/// so a change to it is already understood elsewhere in the codebase as
82/// meaning "this is a different skill."
83///
84/// The namespace is itself derived via `Uuid::new_v5` from a fixed, documented
85/// seed string rather than a hand-picked random constant, so the value here
86/// is reproducible and auditable from this source file alone rather than an
87/// opaque UUID nobody can regenerate if this file is ever lost.
88pub(crate) fn starter_skill_id(trigger: &str) -> Uuid {
89    let namespace = Uuid::new_v5(&Uuid::NAMESPACE_URL, b"https://agentmux.ai/catalog/skills/v1");
90    Uuid::new_v5(&namespace, trigger.as_bytes())
91}
92
93/// Look up a starter skill's `trigger` by its `name`, for a caller that has
94/// only a `name` in hand and needs to recompute [`starter_skill_id`] for it —
95/// e.g. the durable-bindings carry-across migration, which must converge a
96/// legacy (pre-Phase-1, randomly-id'd) starter row onto the SAME id a fresh
97/// post-Phase-1 channel would independently mint for it, not onto whichever
98/// random id happened to reach the identity store first.
99///
100/// Returns `None` for anything not in the embedded manifest — including a
101/// user-promoted global skill that merely happens to share the concept of
102/// `is_global = 1` with the six starters. Only a recognized starter has a
103/// canonical id to converge onto; everything else keeps whatever id survives
104/// the migration's ordinary first-wins dedup.
105#[cfg(test)]
106pub(crate) fn starter_skill_trigger_for_name(name: &str) -> Option<String> {
107    let manifest: Vec<StarterSkill> = serde_json::from_str(STARTER_SKILLS_JSON).ok()?;
108    manifest.into_iter().find(|s| s.name == name).map(|s| s.trigger)
109}
110
111/// Guard against a manifest-authoring mistake: two entries sharing a
112/// `trigger`.
113///
114/// Deterministic ids remove a safety net that random ids provided by
115/// accident. Before this change, a duplicate `trigger` with a different
116/// `name` would insert as two independent rows (no id collision, no name
117/// collision — nobody was checking `trigger` for uniqueness either way,
118/// this was already a latent gap). After this change it is worse in a
119/// different way: both entries now mint the SAME id, so the second insert's
120/// `managed_upsert_unique_global` duplicate-name check
121/// (`WHERE name = ?1 AND id <> ?2`) excludes the first entry's own row from
122/// consideration — because its id now equals the second entry's id — and the
123/// `INSERT ... ON CONFLICT(id) DO UPDATE` silently overwrites it instead of
124/// erroring. `seed_starter_skills`'s `created` count would even report six
125/// when only five distinct rows exist (reagent P2, PR #3175).
126///
127/// Unreachable today — `starter-skills.json` has six distinct triggers, see
128/// the test pinning that — but it must fail LOUDLY the moment it stops being
129/// unreachable, the same way the pre-existing name-uniqueness check already
130/// does for other manifest mistakes, rather than silently merging two
131/// authored skills into one.
132fn reject_duplicate_triggers(manifest: &[StarterSkill]) -> Result<(), StoreError> {
133    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
134    for entry in manifest {
135        if !seen.insert(entry.trigger.as_str()) {
136            return Err(StoreError::Other(format!(
137                "skill seed: manifest has more than one entry with trigger '{}' — \
138                 this is an authoring bug in starter-skills.json, not a runtime \
139                 condition to recover from",
140                entry.trigger
141            )));
142        }
143    }
144    Ok(())
145}
146
147/// True if any global skill whose name matches a starter-skill's name
148/// already exists — i.e. this channel already effectively has the starter
149/// set (or a name collision with it), whether from a prior run of this
150/// migration, the retired pre-migration startup-seed path (#2141, shipped
151/// before this migration existed — every channel that has already booted
152/// once has these names present), or the user coincidentally naming their
153/// own skill the same thing.
154///
155/// The caller (`migrations::m0015_seed_starter_skills`) uses this to skip
156/// seeding entirely rather than attempting an insert that would collide on
157/// `skill_upsert_unique_global`'s name-uniqueness check and permanently
158/// fail the migration on every subsequent boot (reagent P1, PR #2144).
159pub(crate) fn any_starter_skill_name_exists(mstore: &Arc<Store>) -> Result<bool, StoreError> {
160    let manifest: Vec<StarterSkill> = serde_json::from_str(STARTER_SKILLS_JSON)
161        .map_err(|e| StoreError::Other(format!("skill seed: parse manifest: {e}")))?;
162    // Pre-dates Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md's catalog
163    // redirect — this migration always operated on one store's own
164    // db_skills, so `mstore` plays both the ref-owner and catalog role here
165    // (the later carry-across migration, m0031, is what moves these rows
166    // into identity_store).
167    let existing = mstore.skill_list_global(mstore)?;
168    Ok(manifest
169        .iter()
170        .any(|entry| existing.iter().any(|item| item.skill.name == entry.name)))
171}
172
173/// Parse the embedded manifest and insert every entry as a global skill via
174/// the validated `skill_upsert_unique_global` path. Does NOT check whether
175/// the catalog is already populated — the caller
176/// (`migrations::m0015_seed_starter_skills`) owns run-once gating via
177/// `db_migrations` tracking, not catalog contents. Exposed at `pub(crate)`
178/// so the migration and tests can call it directly.
179///
180/// All-or-nothing: `skill_upsert_unique_global` commits each insert in its
181/// own transaction (it isn't `StoreTx`-composable — see its own doc
182/// comment), so a mid-loop failure can't be rolled back by the database
183/// itself. If any insert fails, this compensates by deleting every skill
184/// already inserted in THIS call before returning the error — otherwise a
185/// retry (the migration framework re-runs `up()` on the next boot when a
186/// migration returns `Err`, since it's never marked applied) would hit
187/// `skill_upsert_unique_global`'s name-uniqueness rejection on the skills
188/// already stranded from the failed attempt (reagent P2, PR #2141 round 1).
189pub(crate) fn seed_starter_skills(mstore: &Arc<Store>) -> Result<usize, StoreError> {
190    let manifest: Vec<StarterSkill> = serde_json::from_str(STARTER_SKILLS_JSON)
191        .map_err(|e| StoreError::Other(format!("skill seed: parse manifest: {e}")))?;
192    reject_duplicate_triggers(&manifest)?;
193
194    let now = agentmux_common::time::now_ms();
195
196    let mut inserted_ids: Vec<String> = Vec::with_capacity(manifest.len());
197    for entry in &manifest {
198        let skill = Skill {
199            id: starter_skill_id(&entry.trigger).to_string(),
200            name: entry.name.clone(),
201            trigger: entry.trigger.clone(),
202            skill_type: entry.skill_type.clone(),
203            description: entry.description.clone(),
204            content: entry.content.clone(),
205            is_global: true,
206            created_at: now,
207            updated_at: now,
208        };
209        if let Err(e) = mstore.skill_upsert_unique_global(&skill) {
210            for id in &inserted_ids {
211                if let Err(cleanup_err) = mstore.skill_delete(mstore, id) {
212                    tracing::error!(
213                        "skill seed: cleanup after partial failure could not remove {id}: {cleanup_err}"
214                    );
215                }
216            }
217            return Err(e);
218        }
219        inserted_ids.push(skill.id);
220    }
221
222    Ok(inserted_ids.len())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn starter(trigger: &str, name: &str) -> StarterSkill {
230        StarterSkill {
231            name: name.to_string(),
232            trigger: trigger.to_string(),
233            skill_type: "prompt".to_string(),
234            description: "d".to_string(),
235            content: "c".to_string(),
236        }
237    }
238
239    #[test]
240    fn reject_duplicate_triggers_fails_loudly_on_a_colliding_manifest() {
241        // reagent P2, PR #3175: without this guard, two entries sharing a
242        // trigger now mint the SAME deterministic id, and the second insert
243        // silently overwrites the first instead of erroring — see this
244        // function's own doc comment for the full mechanism. Must fail
245        // BEFORE any row is touched, the way the old random-id behavior
246        // failed loudly on a duplicate NAME.
247        let manifest = vec![
248            starter("dup", "First Skill"),
249            starter("dup", "Second Skill, Different Name"),
250        ];
251        let err = reject_duplicate_triggers(&manifest).unwrap_err();
252        assert!(format!("{err}").contains("dup"), "error must name the offending trigger: {err}");
253    }
254
255    #[test]
256    fn reject_duplicate_triggers_accepts_the_real_manifest() {
257        // The guard must never fire on starter-skills.json as it ships.
258        let manifest: Vec<StarterSkill> = serde_json::from_str(STARTER_SKILLS_JSON).unwrap();
259        assert!(reject_duplicate_triggers(&manifest).is_ok());
260    }
261
262    #[test]
263    fn starter_skill_ids_are_identical_across_two_independently_seeded_stores() {
264        // The property Phase 1 of SPEC_DURABLE_BINDINGS_2026_09_10.md exists
265        // for: two stores that have never seen each other seed the SAME
266        // starter skill under the SAME id. This is what lets a later
267        // cross-store reconciliation recognize them as one resource without
268        // guessing from the name.
269        let store_a = Arc::new(Store::open_in_memory().unwrap());
270        let store_b = Arc::new(Store::open_in_memory().unwrap());
271
272        seed_starter_skills(&store_a).unwrap();
273        seed_starter_skills(&store_b).unwrap();
274
275        let mut ids_a: Vec<(String, String)> = store_a
276            .skill_list_global(&store_a)
277            .unwrap()
278            .into_iter()
279            .map(|item| (item.skill.name, item.skill.id))
280            .collect();
281        let mut ids_b: Vec<(String, String)> = store_b
282            .skill_list_global(&store_b)
283            .unwrap()
284            .into_iter()
285            .map(|item| (item.skill.name, item.skill.id))
286            .collect();
287        ids_a.sort();
288        ids_b.sort();
289
290        assert_eq!(ids_a.len(), 6);
291        assert_eq!(ids_a, ids_b, "the same starter skill must get the same id in every store");
292    }
293
294    #[test]
295    fn starter_skill_id_changes_only_with_trigger_not_with_name_or_content() {
296        // Renaming the DISPLAY text or fixing a typo in the body must not
297        // mint a new identity — only the trigger (the stable slug) does.
298        let id_before = starter_skill_id("tdd");
299        let id_after_reword = starter_skill_id("tdd");
300        assert_eq!(id_before, id_after_reword, "identical trigger must always produce the identical id");
301
302        let id_different_trigger = starter_skill_id("test-driven-development");
303        assert_ne!(
304            id_before, id_different_trigger,
305            "a different trigger is treated as a different skill, as intended"
306        );
307    }
308
309    #[test]
310    fn seeds_six_skills_into_an_empty_catalog() {
311        let mstore = Arc::new(Store::open_in_memory().unwrap());
312        assert!(mstore.skill_list_global(&mstore).unwrap().is_empty());
313
314        let created = seed_starter_skills(&mstore).unwrap();
315
316        assert_eq!(created, 6);
317        let after = mstore.skill_list_global(&mstore).unwrap();
318        assert_eq!(after.len(), 6, "all six starter skills should be seeded");
319        assert!(after.iter().all(|item| item.skill.is_global));
320    }
321
322    #[test]
323    fn a_failed_insert_rolls_back_the_ones_already_seeded_this_call() {
324        // Reagent P2 (PR #2141 round 1): skill_upsert_unique_global commits
325        // each insert in its own transaction, so a mid-loop failure can't
326        // be rolled back by the database. Simulate that failure mode by
327        // pre-inserting a global skill whose NAME collides with one of the
328        // starter skills (skill_upsert_unique_global rejects duplicate
329        // names) — this forces seed_starter_skills to fail partway through
330        // the manifest, and the catalog must end up back at exactly the
331        // one pre-existing skill, not a stranded partial starter set.
332        let mstore = Arc::new(Store::open_in_memory().unwrap());
333        let now = agentmux_common::time::now_ms();
334
335        // "Test-Driven Development" is the second entry in the manifest —
336        // colliding on it guarantees at least one successful insert
337        // (Systematic Debugging) precedes the failure, so the rollback
338        // path actually has something to clean up.
339        let colliding = Skill {
340            id: "pre-existing-collision".to_string(),
341            name: "Test-Driven Development".to_string(),
342            trigger: "already-here".to_string(),
343            skill_type: "prompt".to_string(),
344            description: "Pre-existing skill that collides with a starter skill's name.".to_string(),
345            content: "n/a".to_string(),
346            is_global: true,
347            created_at: now,
348            updated_at: now,
349        };
350        mstore.skill_upsert_unique_global(&colliding).unwrap();
351
352        let result = seed_starter_skills(&mstore);
353        assert!(result.is_err(), "seeding must fail when a name collides");
354
355        let after = mstore.skill_list_global(&mstore).unwrap();
356        assert_eq!(
357            after.len(),
358            1,
359            "a failed seed must roll back every skill it inserted this call, leaving only the pre-existing one"
360        );
361        assert_eq!(after[0].skill.id, "pre-existing-collision");
362    }
363}