awaken_server/services/builtin_seed/mod.rs
1//! Protocol for applying a [`BuiltinSeedSet`] to a [`ConfigStore`].
2//!
3//! See [`apply_builtin_seed`] for the full semantics.
4
5use std::collections::{HashMap, HashSet};
6
7use awaken_server_contract::contract::storage::StorageError;
8use awaken_server_contract::{
9 BuiltinSeedSet, BuiltinSpec, ConfigRecord, ConfigStore, RecordMeta, RecordSource, SkillSpec,
10 validate_model_pool_spec_struct,
11};
12
13const SEED_LIST_PAGE_SIZE: usize = 256;
14const BUILTIN_SEED_NAMESPACES: [&str; 7] = [
15 "agents",
16 "providers",
17 "models",
18 "model-pools",
19 "mcp-servers",
20 "tools",
21 "skills",
22];
23
24// ── public types ─────────────────────────────────────────────────────────────
25
26/// Report produced by [`apply_builtin_seed`].
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct SeedReport {
29 pub created: Vec<RecordRef>,
30 pub updated: Vec<RecordRef>,
31 pub unchanged: Vec<RecordRef>,
32 pub deleted: Vec<RecordRef>,
33 pub preserved_user: Vec<RecordRef>,
34 /// Builtin records orphaned by this seed (id no longer registered) but
35 /// carrying a non-empty `user_overrides`. Marked `hidden=true` instead
36 /// of being deleted, so re-introducing the spec in a later binary
37 /// transparently restores the override.
38 pub preserved_overridden: Vec<RecordRef>,
39}
40
41/// Identifies a single record in a ConfigStore.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RecordRef {
44 pub namespace: String,
45 pub id: String,
46}
47
48impl RecordRef {
49 fn new(namespace: &str, id: &str) -> Self {
50 Self {
51 namespace: namespace.to_owned(),
52 id: id.to_owned(),
53 }
54 }
55}
56
57/// Errors returned by [`apply_builtin_seed`].
58#[derive(Debug, thiserror::Error)]
59pub enum SeedError {
60 #[error("storage error: {0}")]
61 Storage(#[from] StorageError),
62 #[error("serialization error: {0}")]
63 Serde(#[from] serde_json::Error),
64 /// Built-in agent spec failed `AgentSpec::validate_catalog`. Mirrors
65 /// the write-path guard in `ConfigService::validate_payload`.
66 #[error("agent spec '{id}' has invalid tool catalog: {errors}")]
67 InvalidAgentCatalog { id: String, errors: String },
68 /// Built-in skill spec failed the config write-path validation.
69 #[error("skill spec '{id}' is invalid: {errors}")]
70 InvalidSkillSpec { id: String, errors: String },
71 /// Built-in model pool spec failed the config write-path validation.
72 #[error("model pool spec '{id}' is invalid: {errors}")]
73 InvalidModelPoolSpec { id: String, errors: String },
74}
75
76// ── apply_builtin_seed ────────────────────────────────────────────────────────
77
78/// Apply a seed to the given ConfigStore.
79///
80/// Behavior per spec in `seed.specs`:
81/// - No existing record → create new Builtin record. (created)
82/// - Existing Builtin, same binary_version, spec equal → no-op. (unchanged)
83/// - Existing Builtin, same binary_version, spec differs → replace spec, refresh updated_at. (updated)
84/// - Existing Builtin, different binary_version → replace spec + version, clear hidden, refresh updated_at. (updated)
85/// - Existing User → leave entirely untouched. (preserved_user)
86///
87/// After processing seed entries, scans all built-in spec namespaces
88/// (`agents`, `providers`, `models`, `mcp-servers`, `tools`, `skills`) and processes
89/// each Builtin record whose ID is not in this seed:
90///
91/// - If it carries a `user_overrides` payload → marks it `hidden=true` instead
92/// of deleting, so re-introducing the spec in a later binary transparently
93/// restores the override (preserved_overridden).
94/// - Otherwise → hard-deletes it (deleted).
95///
96/// User records are never deleted by orphan cleanup.
97///
98/// Seed writes use ConfigStore CAS primitives so a concurrent writer surfaces as
99/// a storage conflict instead of silently overwriting records.
100pub async fn apply_builtin_seed(
101 store: &dyn ConfigStore,
102 seed: &BuiltinSeedSet,
103) -> Result<SeedReport, SeedError> {
104 // Reject the whole seed up-front on any unparseable catalog pattern, so
105 // bad syntax can't enter the store via the seed path and only surface
106 // later as a resolve-time "no tools matched" warning.
107 for spec in &seed.specs {
108 match spec {
109 BuiltinSpec::Agent(agent) => validate_agent_spec_catalog(agent)?,
110 BuiltinSpec::ModelPool(pool) => {
111 validate_model_pool_spec_struct(pool).map_err(|error| {
112 SeedError::InvalidModelPoolSpec {
113 id: pool.id.clone(),
114 errors: error.to_string(),
115 }
116 })?;
117 }
118 BuiltinSpec::Skill(skill) => validate_builtin_skill_spec(skill)?,
119 _ => {}
120 }
121 }
122
123 let mut report = SeedReport::default();
124
125 // Track seeded (namespace, id) pairs for orphan cleanup.
126 let mut seeded: HashMap<&str, HashSet<String>> = HashMap::new();
127 for ns in BUILTIN_SEED_NAMESPACES {
128 seeded.insert(ns, HashSet::new());
129 }
130
131 // ── Phase 1: upsert seed entries ────────────────────────────────────────
132 for spec in &seed.specs {
133 let namespace = spec.namespace();
134 let id = spec.id();
135 let new_spec_value = builtin_spec_to_value(spec)?;
136
137 seeded.entry(namespace).or_default().insert(id.to_owned());
138
139 let existing_raw = store.get(namespace, id).await?;
140
141 match existing_raw {
142 None => {
143 // Create new Builtin record.
144 let mut record = ConfigRecord {
145 spec: new_spec_value,
146 meta: RecordMeta::new_builtin(&seed.binary_version),
147 };
148 record.meta.revision = 1;
149 store
150 .put_if_absent(namespace, id, &record.to_value()?)
151 .await?;
152 report.created.push(RecordRef::new(namespace, id));
153 }
154 Some(raw) => {
155 let existing: ConfigRecord<serde_json::Value> = ConfigRecord::from_value(raw)?;
156
157 match &existing.meta.source {
158 RecordSource::User => {
159 // Never touch user records.
160 report.preserved_user.push(RecordRef::new(namespace, id));
161 }
162 RecordSource::Builtin {
163 binary_version: stored_version,
164 } => {
165 let same_version = stored_version == &seed.binary_version;
166 let same_spec = existing.spec == new_spec_value;
167
168 if same_version && same_spec {
169 // No-op.
170 report.unchanged.push(RecordRef::new(namespace, id));
171 } else {
172 // Update: refresh spec and/or version; preserve
173 // user_overrides and created_at.
174 // Reintroducing a previously-orphaned spec clears
175 // `hidden`; the user override (if any) flows
176 // through unchanged.
177 let now = awaken_server_contract::time::now_ms();
178 let expected_revision = existing.meta.revision;
179 let record = ConfigRecord {
180 spec: new_spec_value,
181 meta: RecordMeta {
182 source: RecordSource::Builtin {
183 binary_version: seed.binary_version.clone(),
184 },
185 hidden: false,
186 user_overrides: existing.meta.user_overrides,
187 created_at: existing.meta.created_at,
188 updated_at: now,
189 revision: expected_revision + 1,
190 },
191 };
192 store
193 .put_if_revision(
194 namespace,
195 id,
196 &record.to_value()?,
197 expected_revision,
198 )
199 .await?;
200 report.updated.push(RecordRef::new(namespace, id));
201 }
202 }
203 }
204 }
205 }
206 }
207
208 // ── Phase 2: orphan cleanup ──────────────────────────────────────────────
209 //
210 // Two-pass snapshot-then-delete to avoid the pagination skew that
211 // interleaved deletes would cause: deleting a record shifts later entries
212 // forward in the store's ordering, so a single combined loop would skip
213 // records that move into already-visited slots.
214 //
215 // Pass 1 (read-only): collect all deletion candidates into a Vec.
216 // Pass 2 (write): delete each candidate.
217 //
218 // Safe under the boot-time single-writer precondition documented above.
219 for namespace in BUILTIN_SEED_NAMESPACES {
220 let empty = HashSet::new();
221 let seeded_ids: &HashSet<String> = seeded.get(namespace).unwrap_or(&empty);
222
223 // Pass 1: snapshot deletion candidates.
224 let mut candidates: Vec<String> = Vec::new();
225 let mut offset = 0usize;
226 loop {
227 let page = store.list(namespace, offset, SEED_LIST_PAGE_SIZE).await?;
228 let page_len = page.len();
229
230 for (id, raw) in page {
231 if seeded_ids.contains(&id) {
232 continue;
233 }
234 // Decode to check source; legacy bare-spec becomes User.
235 let record: ConfigRecord<serde_json::Value> = ConfigRecord::from_value(raw)?;
236 if matches!(record.meta.source, RecordSource::Builtin { .. }) {
237 candidates.push(id);
238 }
239 // User records (including legacy-bare ones) are left alone.
240 }
241
242 if page_len < SEED_LIST_PAGE_SIZE {
243 break;
244 }
245 offset += page_len;
246 }
247
248 // Pass 2: delete or hide each candidate based on whether it carries
249 // a user override. Hard-delete records with no override; soft-delete
250 // (hidden=true) records that DO have an override so that re-introducing
251 // the spec in a later binary transparently restores the override.
252 for id in candidates {
253 let Some(raw) = store.get(namespace, &id).await? else {
254 continue;
255 };
256 let mut record: ConfigRecord<serde_json::Value> = ConfigRecord::from_value(raw)?;
257 let expected_revision = record.meta.revision;
258
259 if record.meta.user_overrides.is_some() {
260 // Soft-delete: preserve the override under hidden=true.
261 record.meta.hidden = true;
262 record.meta.updated_at = awaken_server_contract::time::now_ms();
263 record.meta.revision = expected_revision + 1;
264 store
265 .put_if_revision(namespace, &id, &record.to_value()?, expected_revision)
266 .await?;
267 report
268 .preserved_overridden
269 .push(RecordRef::new(namespace, &id));
270 } else {
271 store
272 .delete_if_revision(namespace, &id, expected_revision)
273 .await?;
274 report.deleted.push(RecordRef::new(namespace, &id));
275 }
276 }
277 }
278
279 Ok(report)
280}
281
282// ── helper ───────────────────────────────────────────────────────────────────
283
284/// Extract the inner spec JSON from a [`BuiltinSpec`].
285///
286/// The wire format stored in the envelope's `spec` field is the plain inner
287/// spec (e.g. `AgentSpec` JSON), not the tagged `BuiltinSpec` form.
288fn builtin_spec_to_value(spec: &BuiltinSpec) -> Result<serde_json::Value, serde_json::Error> {
289 match spec {
290 BuiltinSpec::Agent(s) => serde_json::to_value(s.as_ref()),
291 BuiltinSpec::Provider(s) => serde_json::to_value(s),
292 BuiltinSpec::Model(s) => serde_json::to_value(s),
293 BuiltinSpec::ModelPool(s) => serde_json::to_value(s),
294 BuiltinSpec::A2aServer(s) => serde_json::to_value(s),
295 BuiltinSpec::McpServer(s) => serde_json::to_value(s),
296 BuiltinSpec::Tool(s) => serde_json::to_value(s),
297 BuiltinSpec::Skill(s) => serde_json::to_value(s),
298 }
299}
300
301/// Enforce `AgentSpec::validate_catalog` on a builtin agent, surfacing
302/// errors as `InvalidAgentCatalog`. Mirrors the write-path policy.
303fn validate_agent_spec_catalog(spec: &awaken_server_contract::AgentSpec) -> Result<(), SeedError> {
304 let errors = crate::services::agent_catalog::collect_catalog_errors(spec);
305 if errors.is_empty() {
306 Ok(())
307 } else {
308 Err(SeedError::InvalidAgentCatalog {
309 id: spec.id.clone(),
310 errors: errors.join("; "),
311 })
312 }
313}
314
315/// Enforce `SkillSpec` write-path validation on builtin skills.
316fn validate_builtin_skill_spec(spec: &SkillSpec) -> Result<(), SeedError> {
317 let value = serde_json::to_value(spec)?;
318 awaken_server_contract::validate_skill_spec(value).map_err(|error| {
319 SeedError::InvalidSkillSpec {
320 id: spec.id.clone(),
321 errors: error.to_string(),
322 }
323 })?;
324 Ok(())
325}
326
327// ── tests ─────────────────────────────────────────────────────────────────────
328//
329// Inline tests live in `tests.rs` to keep this file under the lefthook
330// code-file-length guard.
331
332#[cfg(test)]
333mod tests;