1use std::path::{Path, PathBuf};
20use std::sync::Mutex;
21
22use serde_json::Value;
23use thiserror::Error;
24
25use super::atomic::{rename_atomic, write_atomic};
26use super::def_schema::{validate, DefValidationError, DefinitionRecord, DEF_MAX_SUPPORTED_SCHEMA};
27
28#[derive(Debug, Error)]
29pub enum DefStoreError {
30 #[error("io: {0}")]
31 Io(#[from] std::io::Error),
32 #[error("json: {0}")]
33 Json(#[from] serde_json::Error),
34 #[error("validation: {0}")]
35 Validation(#[from] DefValidationError),
36}
37
38pub struct DefinitionStore {
39 root: PathBuf,
40 write_lock: Mutex<()>,
41}
42
43impl DefinitionStore {
44 pub fn open(root: PathBuf) -> Result<Self, DefStoreError> {
47 std::fs::create_dir_all(&root)?;
48 std::fs::create_dir_all(root.join("retired"))?;
49 Ok(Self {
50 root,
51 write_lock: Mutex::new(()),
52 })
53 }
54
55 pub fn root(&self) -> &Path {
56 &self.root
57 }
58
59 fn active_path(&self, id: &str) -> PathBuf {
60 self.root.join(format!("{id}.json"))
61 }
62
63 fn retired_path(&self, id: &str) -> PathBuf {
64 self.root.join("retired").join(format!("{id}.json"))
65 }
66
67 pub fn upsert(&self, rec: &DefinitionRecord) -> Result<(), DefStoreError> {
71 let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
72 let path = self.active_path(&rec.data.id);
73 let bytes = match std::fs::read(&path) {
74 Ok(existing) => match merge_for_write(&existing, rec)? {
75 Some(b) => b,
76 None => return Ok(()),
77 },
78 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
79 if self.retired_path(&rec.data.id).exists() {
86 return Ok(());
87 }
88 to_pretty(rec)?
89 }
90 Err(e) => return Err(e.into()),
91 };
92 write_atomic(&path, &bytes)?;
93 Ok(())
94 }
95
96 pub fn retire(&self, id: &str) -> Result<(), DefStoreError> {
100 let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
101 let from = self.active_path(id);
102 if !from.exists() {
103 return Ok(());
104 }
105 rename_atomic(&from, &self.retired_path(id))?;
106 Ok(())
107 }
108
109 pub fn unretire(&self, id: &str) -> Result<(), DefStoreError> {
111 let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
112 let from = self.retired_path(id);
113 if !from.exists() {
114 return Ok(());
115 }
116 rename_atomic(&from, &self.active_path(id))?;
117 Ok(())
118 }
119
120 pub fn hard_delete(&self, id: &str) -> Result<(), DefStoreError> {
124 let _g = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
125 for p in [self.active_path(id), self.retired_path(id)] {
126 match std::fs::remove_file(&p) {
127 Ok(()) => {}
128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
129 Err(e) => return Err(e.into()),
130 }
131 }
132 Ok(())
133 }
134
135 pub fn exists(&self, id: &str) -> bool {
136 self.active_path(id).exists()
137 }
138
139 pub fn exists_anywhere(&self, id: &str) -> bool {
140 self.active_path(id).exists() || self.retired_path(id).exists()
141 }
142
143 pub fn get(&self, id: &str) -> Result<Option<DefinitionRecord>, DefStoreError> {
146 match std::fs::read(self.active_path(id)) {
147 Ok(bytes) => {
148 let rec: DefinitionRecord = serde_json::from_slice(&bytes)?;
149 validate(id, &rec)?;
150 Ok(Some(rec))
151 }
152 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
153 Err(e) => Err(e.into()),
154 }
155 }
156
157 pub fn list_active(&self) -> Result<Vec<DefinitionRecord>, DefStoreError> {
160 let mut out = Vec::new();
161 for entry in std::fs::read_dir(&self.root)? {
162 let entry = entry?;
163 let path = entry.path();
164 if !path.is_file() {
165 continue;
166 }
167 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
168 continue;
169 };
170 if path.extension().and_then(|s| s.to_str()) != Some("json") {
171 continue;
172 }
173 match read_and_validate(&path, stem) {
174 Ok(rec) => out.push(rec),
175 Err(e) => {
176 tracing::warn!(
177 file = %path.display(),
178 error = %e,
179 "def registry: skipping invalid record"
180 );
181 }
182 }
183 }
184 Ok(out)
185 }
186}
187
188fn read_and_validate(path: &Path, stem: &str) -> Result<DefinitionRecord, DefStoreError> {
189 let bytes = std::fs::read(path)?;
190 let rec: DefinitionRecord = serde_json::from_slice(&bytes)?;
191 validate(stem, &rec)?;
192 Ok(rec)
193}
194
195fn to_pretty(rec: &DefinitionRecord) -> Result<Vec<u8>, serde_json::Error> {
196 let mut bytes = serde_json::to_vec_pretty(rec)?;
197 bytes.push(b'\n');
198 Ok(bytes)
199}
200
201fn merge_for_write(
207 existing: &[u8],
208 rec: &DefinitionRecord,
209) -> Result<Option<Vec<u8>>, DefStoreError> {
210 let on_disk: Value = match serde_json::from_slice(existing) {
211 Ok(v) => v,
212 Err(e) => {
213 tracing::warn!(
214 error = %e,
215 "def registry: existing file is unparseable JSON — refusing to overwrite"
216 );
217 return Ok(None);
218 }
219 };
220 let on_disk_version = on_disk
223 .get("schema_version")
224 .and_then(|v| v.as_u64())
225 .and_then(|v| u32::try_from(v).ok());
226 match on_disk_version {
227 Some(v) if v > DEF_MAX_SUPPORTED_SCHEMA => {
228 tracing::warn!(
229 on_disk = v,
230 writer_max = DEF_MAX_SUPPORTED_SCHEMA,
231 "def registry: on-disk schema_version > writer max — refusing downgrade"
232 );
233 return Ok(None);
234 }
235 Some(_) => {}
236 None => {
237 tracing::warn!("def registry: existing file lacks schema_version — refusing to overwrite");
238 return Ok(None);
239 }
240 }
241 let mut merged = on_disk;
242 let updates = serde_json::to_value(rec)?;
243 merge_known(&mut merged, &updates);
244 let mut bytes = serde_json::to_vec_pretty(&merged)?;
245 bytes.push(b'\n');
246 Ok(Some(bytes))
247}
248
249fn merge_known(target: &mut Value, updates: &Value) {
252 let (Some(t), Some(u)) = (target.as_object_mut(), updates.as_object()) else {
253 *target = updates.clone();
254 return;
255 };
256 for (k, v) in u {
257 if k == "data" {
258 if let Some(t_data) = t.get_mut("data") {
259 merge_known(t_data, v);
260 continue;
261 }
262 }
263 t.insert(k.clone(), v.clone());
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::registry::DefinitionRecordV1;
271
272 fn store() -> (tempfile::TempDir, DefinitionStore) {
273 let tmp = tempfile::tempdir().unwrap();
274 let s = DefinitionStore::open(tmp.path().join("definitions")).unwrap();
275 (tmp, s)
276 }
277
278 fn rec(id: &str, name: &str) -> DefinitionRecord {
279 DefinitionRecord {
280 schema_version: 1,
281 data: DefinitionRecordV1 {
282 id: id.to_string(),
283 slug: id.to_string(),
284 name: name.to_string(),
285 icon: "✦".to_string(),
286 provider: "claude".to_string(),
287 description: String::new(),
288 working_directory: String::new(),
289 shell: String::new(),
290 provider_flags: String::new(),
291 auto_start: 0,
292 restart_on_crash: 0,
293 idle_timeout_minutes: 0,
294 created_at: 1,
295 agent_type: "host".to_string(),
296 environment: String::new(),
297 agent_bus_id: String::new(),
298 is_seeded: 0,
299 accounts: String::new(),
300 parent_id: String::new(),
301 branch_label: String::new(),
302 updated_at: 1,
303 user_hidden: 0,
304 container_image: String::new(),
305 container_volumes: "[]".to_string(),
306 container_name: String::new(),
307 content: Vec::new(),
308 skills: Vec::new(),
309 },
310 }
311 }
312
313 #[test]
314 fn upsert_then_list() {
315 let (_t, s) = store();
316 s.upsert(&rec("aaa", "Demo")).unwrap();
317 let listed = s.list_active().unwrap();
318 assert_eq!(listed.len(), 1);
319 assert_eq!(listed[0].data.name, "Demo");
320 }
321
322 #[test]
323 fn upsert_update_replaces_known_fields() {
324 let (_t, s) = store();
325 s.upsert(&rec("aaa", "Demo")).unwrap();
326 s.upsert(&rec("aaa", "Renamed")).unwrap();
327 let listed = s.list_active().unwrap();
328 assert_eq!(listed.len(), 1);
329 assert_eq!(listed[0].data.name, "Renamed");
330 }
331
332 #[test]
333 fn retire_then_unretire_round_trips() {
334 let (_t, s) = store();
335 s.upsert(&rec("aaa", "Demo")).unwrap();
336 s.retire("aaa").unwrap();
337 assert!(s.list_active().unwrap().is_empty());
338 assert!(s.exists_anywhere("aaa"));
339 s.unretire("aaa").unwrap();
340 assert_eq!(s.list_active().unwrap().len(), 1);
341 }
342
343 #[test]
344 fn hard_delete_removes_both_paths() {
345 let (_t, s) = store();
346 s.upsert(&rec("aaa", "Demo")).unwrap();
347 s.retire("aaa").unwrap();
348 s.upsert(&rec("bbb", "Demo2")).unwrap();
349 s.hard_delete("aaa").unwrap();
350 s.hard_delete("bbb").unwrap();
351 assert!(!s.exists_anywhere("aaa"));
352 assert!(!s.exists_anywhere("bbb"));
353 }
354
355 #[test]
356 fn unknown_field_survives_older_writer_update() {
357 let (_t, s) = store();
358 let path = s.root().join("aaa.json");
360 let raw = serde_json::json!({
361 "schema_version": 1,
362 "data": {
363 "id": "aaa", "name": "Demo", "provider": "claude",
364 "tags": ["keep-me"]
365 }
366 });
367 std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap();
368 s.upsert(&rec("aaa", "Renamed")).unwrap();
370 let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
371 assert_eq!(on_disk.pointer("/data/tags"), Some(&serde_json::json!(["keep-me"])));
372 assert_eq!(on_disk.pointer("/data/name"), Some(&serde_json::json!("Renamed")));
373 }
374
375 #[test]
376 fn refuses_to_downgrade_higher_schema_on_disk() {
377 let (_t, s) = store();
378 let path = s.root().join("aaa.json");
379 let v999 = serde_json::json!({
380 "schema_version": 999,
381 "data": { "id": "aaa", "future_only": "field" }
382 });
383 std::fs::write(&path, serde_json::to_vec_pretty(&v999).unwrap()).unwrap();
384 s.upsert(&rec("aaa", "Renamed")).unwrap();
385 let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
386 assert_eq!(after.pointer("/schema_version"), Some(&serde_json::json!(999)));
387 assert_eq!(after.pointer("/data/future_only"), Some(&serde_json::json!("field")));
388 }
389
390 #[test]
391 fn upsert_does_not_resurrect_a_tombstoned_definition() {
392 let (_t, s) = store();
393 s.upsert(&rec("aaa", "Demo")).unwrap();
394 s.retire("aaa").unwrap();
395 s.upsert(&rec("aaa", "Resurrected")).unwrap();
397 assert!(
399 s.list_active().unwrap().is_empty(),
400 "tombstoned definition must not be resurrected by upsert"
401 );
402 assert!(s.exists_anywhere("aaa"), "tombstone file still present");
403 s.unretire("aaa").unwrap();
405 assert_eq!(s.list_active().unwrap().len(), 1);
406 }
407}