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}