Skip to main content

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;