Skip to main content

kernel/
registry.rs

1//! The store of owned models: a `models.json` file holding every [`ModelRecord`]
2//! the kernel knows about, with change-detecting writes and corruption recovery.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::persistence::{self, StoreError};
10use crate::records::{ModelRecord, ModelState};
11
12const STORE_FILE: &str = "models.json";
13const SCHEMA_VERSION: u32 = 1;
14
15/// Errors raised by the registry.
16#[derive(Debug, thiserror::Error)]
17pub enum RegistryError {
18    /// The store file could not be decoded. It has been quarantined; the payload
19    /// describes the decode failure.
20    #[error("corrupt registry store: {0}")]
21    CorruptStore(String),
22
23    /// The store was written by a newer schema version than this build supports.
24    /// It is left untouched rather than downgraded, so its data is not lost.
25    #[error("registry store schema {found} is newer than supported {supported}")]
26    FutureSchema {
27        /// The version found on disk.
28        found: u32,
29        /// The newest version this build understands.
30        supported: u32,
31    },
32
33    /// A filesystem or encoding error while reading or writing the store.
34    #[error(transparent)]
35    Store(#[from] StoreError),
36}
37
38#[derive(Debug, Deserialize)]
39struct Envelope {
40    schema_version: u32,
41    models: Vec<ModelRecord>,
42}
43
44#[derive(Serialize)]
45struct EnvelopeRef<'a> {
46    schema_version: u32,
47    models: Vec<&'a ModelRecord>,
48}
49
50/// The in-memory model store, backed by `<directory>/models.json`. Every mutating
51/// method persists the change before returning. Not safe for concurrent writers:
52/// two instances on the same directory each hold their own view and the last to
53/// save wins, so a single process should own one instance.
54#[derive(Debug)]
55pub struct Registry {
56    directory: PathBuf,
57    models: BTreeMap<String, ModelRecord>,
58}
59
60impl Registry {
61    /// Open the registry rooted at `directory`, loading `models.json` if present.
62    /// A missing store opens empty; a corrupt store is quarantined and reported
63    /// as [`RegistryError::CorruptStore`]; a store from a newer schema is left in
64    /// place and reported as [`RegistryError::FutureSchema`]. If the file holds
65    /// two records with the same id, the last one in file order wins.
66    pub fn open(directory: &Path) -> Result<Self, RegistryError> {
67        let file = directory.join(STORE_FILE);
68        let models = match persistence::read_json::<Envelope>(&file) {
69            Ok(Some(envelope)) => {
70                if envelope.schema_version > SCHEMA_VERSION {
71                    return Err(RegistryError::FutureSchema {
72                        found: envelope.schema_version,
73                        supported: SCHEMA_VERSION,
74                    });
75                }
76                envelope
77                    .models
78                    .into_iter()
79                    .map(|record| (record.id.clone(), record))
80                    .collect()
81            }
82            Ok(None) => BTreeMap::new(),
83            Err(StoreError::Corrupt { source, .. }) => {
84                return Err(RegistryError::CorruptStore(source.to_string()));
85            }
86            Err(other) => return Err(RegistryError::Store(other)),
87        };
88        Ok(Self {
89            directory: directory.to_path_buf(),
90            models,
91        })
92    }
93
94    /// The record with `id`, if present.
95    pub fn get(&self, id: &str) -> Option<&ModelRecord> {
96        self.models.get(id)
97    }
98
99    /// Whether a record with `id` is present.
100    pub fn contains(&self, id: &str) -> bool {
101        self.models.contains_key(id)
102    }
103
104    /// The number of records held.
105    pub fn len(&self) -> usize {
106        self.models.len()
107    }
108
109    /// Whether the registry holds no records.
110    pub fn is_empty(&self) -> bool {
111        self.models.is_empty()
112    }
113
114    /// Every record, sorted by display name (case-insensitive) then id.
115    pub fn list(&self) -> Vec<&ModelRecord> {
116        let mut records: Vec<&ModelRecord> = self.models.values().collect();
117        records.sort_by_cached_key(|record| (record.name.to_lowercase(), record.id.clone()));
118        records
119    }
120
121    /// Insert or replace `record`. Returns whether anything changed; an identical
122    /// record is a no-op that touches no disk.
123    pub fn register(&mut self, record: ModelRecord) -> Result<bool, RegistryError> {
124        if self.models.get(&record.id) == Some(&record) {
125            return Ok(false);
126        }
127        self.models.insert(record.id.clone(), record);
128        self.save()?;
129        Ok(true)
130    }
131
132    /// Insert or replace many records, writing once. Returns how many input
133    /// records differed from the store (counted per input record, so two inputs
134    /// with the same id both count even though only the last survives).
135    pub fn register_all(&mut self, records: Vec<ModelRecord>) -> Result<usize, RegistryError> {
136        let mut changed = 0;
137        for record in records {
138            if self.models.get(&record.id) != Some(&record) {
139                self.models.insert(record.id.clone(), record);
140                changed += 1;
141            }
142        }
143        if changed > 0 {
144            self.save()?;
145        }
146        Ok(changed)
147    }
148
149    /// Remove the record with `id`, returning it if it was present.
150    pub fn unregister(&mut self, id: &str) -> Result<Option<ModelRecord>, RegistryError> {
151        let removed = self.models.remove(id);
152        if removed.is_some() {
153            self.save()?;
154        }
155        Ok(removed)
156    }
157
158    /// Set the lifecycle state of `id` if it is present. Returns whether the
159    /// record was present; only a real state change touches disk.
160    pub fn set_state_if_present(
161        &mut self,
162        id: &str,
163        state: ModelState,
164    ) -> Result<bool, RegistryError> {
165        let Some(record) = self.models.get_mut(id) else {
166            return Ok(false);
167        };
168        if record.state == state {
169            return Ok(true);
170        }
171        record.state = state;
172        self.save()?;
173        Ok(true)
174    }
175
176    /// Apply `transform` to each present id. When it returns a record that differs
177    /// from the current one, the record is stored under its own id — if the
178    /// transform changed the id, the old key is removed so the record migrates
179    /// cleanly rather than leaving the map keyed by a stale id. Returns the changed
180    /// records; writes once if anything changed.
181    pub fn update(
182        &mut self,
183        ids: &[String],
184        transform: impl Fn(&ModelRecord) -> Option<ModelRecord>,
185    ) -> Result<Vec<ModelRecord>, RegistryError> {
186        let mut changed = Vec::new();
187        for id in ids {
188            let Some(next) = self.models.get(id).and_then(&transform) else {
189                continue;
190            };
191            if self.models.get(id) == Some(&next) {
192                continue;
193            }
194            if next.id != *id {
195                self.models.remove(id);
196            }
197            self.models.insert(next.id.clone(), next.clone());
198            changed.push(next);
199        }
200        if !changed.is_empty() {
201            self.save()?;
202        }
203        Ok(changed)
204    }
205
206    fn save(&self) -> Result<(), RegistryError> {
207        let envelope = EnvelopeRef {
208            schema_version: SCHEMA_VERSION,
209            models: self.models.values().collect(),
210        };
211        persistence::write_json_atomic(&self.directory.join(STORE_FILE), &envelope)?;
212        Ok(())
213    }
214}