1use serde::{Deserialize, Serialize};
9
10use crate::agent_spec_patch::{AgentSpecPatch, merge_agent_spec};
11use crate::registry_spec::{
12 A2aServerSpec, AgentSpec, BackendConfigError, McpServerSpec, ModelPoolSpec, ModelSpec,
13 ProviderSpec,
14};
15use crate::skill_spec::SkillSpec;
16use crate::skill_spec_patch::{SkillSpecPatch, merge_skill_spec};
17use crate::tool_spec::ToolSpec;
18use crate::tool_spec_patch::{ToolSpecPatch, merge_tool_spec};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ConfigRecord<T> {
23 pub spec: T,
24 pub meta: RecordMeta,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct RecordMeta {
30 pub source: RecordSource,
31 #[serde(default)]
32 pub hidden: bool,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub user_overrides: Option<serde_json::Value>,
38 #[serde(default)]
41 pub created_at: u64,
42 #[serde(default)]
43 pub updated_at: u64,
44 #[serde(default)]
49 pub revision: u64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55pub enum RecordSource {
56 Builtin { binary_version: String },
59 User,
61}
62
63#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
69#[serde(deny_unknown_fields)]
70pub struct NoConfigPatch {}
71
72pub trait ConfigRecordMerge: Sized {
74 type Patch: serde::de::DeserializeOwned;
75
76 fn merge_patch(self, patch: Self::Patch) -> Result<Self, ConfigRecordError>;
77}
78
79impl ConfigRecordMerge for AgentSpec {
80 type Patch = AgentSpecPatch;
81
82 fn merge_patch(self, patch: AgentSpecPatch) -> Result<Self, ConfigRecordError> {
83 merge_agent_spec(self, patch).map_err(ConfigRecordError::BackendConfig)
84 }
85}
86
87impl ConfigRecordMerge for ToolSpec {
88 type Patch = ToolSpecPatch;
89
90 fn merge_patch(self, patch: ToolSpecPatch) -> Result<Self, ConfigRecordError> {
91 Ok(merge_tool_spec(self, patch))
92 }
93}
94
95impl ConfigRecordMerge for SkillSpec {
96 type Patch = SkillSpecPatch;
97
98 fn merge_patch(self, patch: SkillSpecPatch) -> Result<Self, ConfigRecordError> {
99 Ok(merge_skill_spec(self, patch))
100 }
101}
102
103impl ConfigRecordMerge for ProviderSpec {
104 type Patch = NoConfigPatch;
105
106 fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
107 Ok(self)
108 }
109}
110
111impl ConfigRecordMerge for ModelSpec {
112 type Patch = NoConfigPatch;
113
114 fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
115 Ok(self)
116 }
117}
118
119impl ConfigRecordMerge for ModelPoolSpec {
120 type Patch = NoConfigPatch;
121
122 fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
123 Ok(self)
124 }
125}
126
127impl ConfigRecordMerge for McpServerSpec {
128 type Patch = NoConfigPatch;
129
130 fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
131 Ok(self)
132 }
133}
134
135impl ConfigRecordMerge for A2aServerSpec {
136 type Patch = NoConfigPatch;
137
138 fn merge_patch(self, _patch: NoConfigPatch) -> Result<Self, ConfigRecordError> {
139 Ok(self)
140 }
141}
142
143#[derive(Debug, thiserror::Error)]
145pub enum ConfigRecordError {
146 #[error("invalid config record: {0}")]
147 Decode(#[source] serde_json::Error),
148 #[error("invalid config record overrides: {0}")]
149 Overrides(#[source] serde_json::Error),
150 #[error("invalid config record backend: {0}")]
151 BackendConfig(#[source] BackendConfigError),
152}
153
154impl<T: serde::de::DeserializeOwned> ConfigRecord<T> {
155 pub fn from_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
162 if is_envelope(&value) {
163 serde_json::from_value(value)
164 } else {
165 let spec: T = serde_json::from_value(value)?;
166 Ok(Self {
167 spec,
168 meta: RecordMeta::legacy_user(),
169 })
170 }
171 }
172}
173
174pub fn decode_config_record<T>(
177 value: serde_json::Value,
178) -> Result<ConfigRecord<T>, ConfigRecordError>
179where
180 T: serde::de::DeserializeOwned,
181{
182 ConfigRecord::from_value(value).map_err(ConfigRecordError::Decode)
183}
184
185pub fn validate_config_record<T>(
188 value: serde_json::Value,
189) -> Result<ConfigRecord<T>, ConfigRecordError>
190where
191 T: serde::de::DeserializeOwned + ConfigRecordMerge,
192{
193 let record = decode_config_record::<T>(value)?;
194 validate_config_record_overrides::<T>(&record)?;
195 Ok(record)
196}
197
198pub fn validate_config_record_overrides<T>(
200 record: &ConfigRecord<T>,
201) -> Result<(), ConfigRecordError>
202where
203 T: ConfigRecordMerge,
204{
205 if let Some(overrides) = &record.meta.user_overrides {
206 serde_json::from_value::<T::Patch>(overrides.clone())
207 .map_err(ConfigRecordError::Overrides)?;
208 }
209 Ok(())
210}
211
212pub fn effective_config_record<T>(record: ConfigRecord<T>) -> Result<T, ConfigRecordError>
214where
215 T: ConfigRecordMerge,
216{
217 let Some(overrides) = record.meta.user_overrides else {
218 return Ok(record.spec);
219 };
220 let patch: T::Patch =
221 serde_json::from_value(overrides).map_err(ConfigRecordError::Overrides)?;
222 record.spec.merge_patch(patch)
223}
224
225pub fn effective_visible_config_records<T, I>(records: I) -> Result<Vec<T>, ConfigRecordError>
230where
231 T: serde::de::DeserializeOwned + ConfigRecordMerge,
232 I: IntoIterator<Item = serde_json::Value>,
233{
234 let mut out = Vec::new();
235 for value in records {
236 let record = validate_config_record::<T>(value)?;
237 if record.meta.hidden {
238 continue;
239 }
240 out.push(effective_config_record(record)?);
241 }
242 Ok(out)
243}
244
245impl<T: Serialize> ConfigRecord<T> {
246 pub fn to_value(&self) -> Result<serde_json::Value, serde_json::Error> {
248 serde_json::to_value(self)
249 }
250}
251
252impl RecordMeta {
253 pub fn legacy_user() -> Self {
256 Self {
257 source: RecordSource::User,
258 hidden: false,
259 user_overrides: None,
260 created_at: 0,
261 updated_at: 0,
262 revision: 0,
263 }
264 }
265
266 pub fn new_user() -> Self {
268 let now = crate::time::now_ms();
269 Self {
270 source: RecordSource::User,
271 hidden: false,
272 user_overrides: None,
273 created_at: now,
274 updated_at: now,
275 revision: 0,
276 }
277 }
278
279 pub fn new_builtin(binary_version: impl Into<String>) -> Self {
281 let now = crate::time::now_ms();
282 Self {
283 source: RecordSource::Builtin {
284 binary_version: binary_version.into(),
285 },
286 hidden: false,
287 user_overrides: None,
288 created_at: now,
289 updated_at: now,
290 revision: 0,
291 }
292 }
293}
294
295fn is_envelope(value: &serde_json::Value) -> bool {
296 matches!(value, serde_json::Value::Object(map) if map.contains_key("spec") && map.contains_key("meta"))
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn legacy_json_without_revision_deserialises_to_zero() {
305 let json = serde_json::json!({
307 "source": {"kind": "user"},
308 "hidden": false,
309 "created_at": 1000,
310 "updated_at": 2000
311 });
312 let meta: RecordMeta = serde_json::from_value(json).unwrap();
313 assert_eq!(meta.revision, 0);
314 assert_eq!(meta.created_at, 1000);
315 assert_eq!(meta.updated_at, 2000);
316 }
317
318 #[test]
319 fn round_trip_preserves_revision() {
320 let meta = RecordMeta {
321 source: RecordSource::User,
322 hidden: false,
323 user_overrides: None,
324 created_at: 100,
325 updated_at: 200,
326 revision: 7,
327 };
328 let serialized = serde_json::to_value(&meta).unwrap();
329 let deserialized: RecordMeta = serde_json::from_value(serialized).unwrap();
330 assert_eq!(deserialized.revision, 7);
331 }
332
333 #[test]
334 fn constructors_default_revision_to_zero() {
335 assert_eq!(RecordMeta::legacy_user().revision, 0);
336 assert_eq!(RecordMeta::new_user().revision, 0);
337 assert_eq!(RecordMeta::new_builtin("1.0.0").revision, 0);
338 }
339}