1use std::sync::Arc;
2
3use awaken_server_contract::AuditAction;
4use awaken_server_contract::contract::config_store::ConfigStore;
5use awaken_server_contract::contract::storage::StorageError;
6use awaken_server_contract::registry_spec::AWAKEN_BACKEND_KIND;
7use awaken_server_contract::{
8 A2aServerSpec, AgentSpec, ConfigRecord, McpServerSpec, ModelPoolSpec, ModelSpec, ProviderSpec,
9 SkillSpec, ToolSpec,
10};
11use axum::http::HeaderMap;
12use serde_json::{Value, json};
13
14use crate::app::{ConfigRoutesState, ServerState};
15use crate::services::audit_log::AuditLogger;
16use crate::services::config_envelope::unwrap_spec;
17
18use super::config_runtime::ConfigRuntimeError;
19
20mod agent_overrides;
21mod audit;
22mod dependencies;
23mod mcp;
24mod normalization;
25mod provider;
26mod restore;
27mod storage;
28mod tool_overrides;
29
30use normalization::{
31 classify_tool_source, effective_spec, effective_tool_spec, effective_visible_record,
32};
33
34pub(super) const TOOLS_NAMESPACE: &str = "tools";
35pub(super) const SKILLS_NAMESPACE: &str = "skills";
36const OVERRIDES_NOT_SUPPORTED_FOR_USER_RECORD: &str =
37 "overrides are not supported for user-source records; use PUT to update";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ConfigNamespace {
41 Agents,
42 Models,
43 ModelPools,
44 Providers,
45 A2aServers,
46 McpServers,
47 Skills,
48}
49
50impl ConfigNamespace {
51 pub const ALL: [Self; 7] = [
53 Self::Agents,
54 Self::Providers,
55 Self::Models,
56 Self::ModelPools,
57 Self::A2aServers,
58 Self::McpServers,
59 Self::Skills,
60 ];
61
62 pub fn all() -> &'static [Self] {
64 &Self::ALL
65 }
66
67 pub fn iter_str() -> impl Iterator<Item = &'static str> + 'static {
69 Self::ALL.iter().copied().map(Self::as_str)
70 }
71
72 pub fn parse(value: &str) -> Result<Self, ConfigServiceError> {
73 match value {
74 "agents" => Ok(Self::Agents),
75 "models" => Ok(Self::Models),
76 "model-pools" => Ok(Self::ModelPools),
77 "providers" => Ok(Self::Providers),
78 "a2a-servers" => Ok(Self::A2aServers),
79 "mcp-servers" => Ok(Self::McpServers),
80 SKILLS_NAMESPACE => Ok(Self::Skills),
81 _ => Err(ConfigServiceError::UnknownNamespace(value.to_string())),
82 }
83 }
84
85 pub fn as_str(self) -> &'static str {
86 match self {
87 Self::Agents => "agents",
88 Self::Models => "models",
89 Self::ModelPools => "model-pools",
90 Self::Providers => "providers",
91 Self::A2aServers => "a2a-servers",
92 Self::McpServers => "mcp-servers",
93 Self::Skills => SKILLS_NAMESPACE,
94 }
95 }
96
97 pub fn schema_json(self) -> Result<Value, ConfigServiceError> {
98 let schema = match self {
99 Self::Agents => schemars::schema_for!(AgentSpec),
100 Self::Models => schemars::schema_for!(ModelSpec),
101 Self::ModelPools => schemars::schema_for!(ModelPoolSpec),
102 Self::Providers => schemars::schema_for!(ProviderSpec),
103 Self::A2aServers => schemars::schema_for!(A2aServerSpec),
104 Self::McpServers => schemars::schema_for!(McpServerSpec),
105 Self::Skills => schemars::schema_for!(SkillSpec),
106 };
107 serde_json::to_value(schema)
108 .map_err(|error| ConfigServiceError::Serialization(error.to_string()))
109 }
110}
111
112pub(crate) fn tool_schema_json() -> Result<Value, ConfigServiceError> {
113 serde_json::to_value(schemars::schema_for!(ToolSpec))
114 .map_err(|error| ConfigServiceError::Serialization(error.to_string()))
115}
116
117#[derive(Debug, Clone, serde::Serialize)]
119pub struct DependentRef {
120 pub namespace: &'static str,
121 pub id: String,
122}
123
124#[derive(Debug, thiserror::Error)]
125pub enum ConfigServiceError {
126 #[error("config management API not enabled")]
127 NotEnabled,
128 #[error("unknown namespace: {0}")]
129 UnknownNamespace(String),
130 #[error("missing 'id' field in body")]
131 MissingId,
132 #[error("invalid payload: {0}")]
133 InvalidPayload(String),
134 #[error("not found: {0}")]
135 NotFound(String),
136 #[error("conflict: {0}")]
137 Conflict(String),
138 #[error("serialization error: {0}")]
139 Serialization(String),
140 #[error("runtime apply failed: {0}")]
141 Apply(String),
142 #[error("storage error: {0}")]
143 Storage(#[from] StorageError),
144}
145
146fn blocked_by_dependents(used_by: Vec<DependentRef>) -> ConfigServiceError {
147 ConfigServiceError::Conflict(format!(
148 "blocked: {used_by:?} record(s) depend on this resource"
149 ))
150}
151
152pub(super) fn overrides_not_supported_for_user_record() -> ConfigServiceError {
153 ConfigServiceError::InvalidPayload(OVERRIDES_NOT_SUPPORTED_FOR_USER_RECORD.into())
154}
155
156pub(crate) fn is_overrides_not_supported_for_user_record(error: &ConfigServiceError) -> bool {
157 matches!(error, ConfigServiceError::InvalidPayload(message) if message == OVERRIDES_NOT_SUPPORTED_FOR_USER_RECORD)
158}
159
160#[derive(Debug, thiserror::Error)]
162pub enum RestoreError {
163 #[error("audit log is not configured")]
164 AuditNotConfigured,
165 #[error("version not found")]
166 VersionNotFound,
167 #[error(
168 "cross-resource restore not allowed: event is for '{event_resource}', expected '{expected}'"
169 )]
170 ResourceMismatch {
171 event_resource: String,
172 expected: String,
173 },
174 #[error("action '{0:?}' does not carry a restorable spec")]
175 NoPayload(AuditAction),
176 #[error("restart events are not restorable")]
177 NotRestorable,
178 #[error("config service error: {0}")]
179 Service(#[from] ConfigServiceError),
180 #[error("storage error: {0}")]
181 Storage(#[from] StorageError),
182}
183
184#[derive(Debug, serde::Serialize)]
186pub struct ProviderTestResult {
187 pub ok: bool,
188 pub latency_ms: u64,
189 pub network_tested: bool,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub error: Option<String>,
192}
193
194pub trait ConfigServiceStateProvider {
195 fn config_service_state(&self) -> Result<ConfigRoutesState, ConfigServiceError>;
196}
197
198impl ConfigServiceStateProvider for ConfigRoutesState {
199 fn config_service_state(&self) -> Result<ConfigRoutesState, ConfigServiceError> {
200 Ok(self.clone())
201 }
202}
203
204impl ConfigServiceStateProvider for ServerState {
205 fn config_service_state(&self) -> Result<ConfigRoutesState, ConfigServiceError> {
206 self.config_routes_state()
207 .ok_or(ConfigServiceError::NotEnabled)
208 }
209}
210
211pub struct ConfigService {
212 state: ConfigRoutesState,
213 store: Arc<dyn ConfigStore>,
214 audit: Option<Arc<AuditLogger>>,
215}
216
217impl ConfigService {
218 pub fn new<S: ConfigServiceStateProvider + ?Sized>(
219 state: &S,
220 ) -> Result<Self, ConfigServiceError> {
221 let state = state.config_service_state()?;
222 let store = state.config.config_store.clone();
223 let audit = state.config.audit_log.clone();
224 Ok(Self {
225 state,
226 store,
227 audit,
228 })
229 }
230
231 pub async fn capabilities(&self) -> Result<Value, ConfigServiceError> {
232 let registries = self
233 .state
234 .run
235 .runtime
236 .registry_set()
237 .ok_or(ConfigServiceError::Apply(
238 "runtime does not expose a configurable registry snapshot".into(),
239 ))?;
240
241 let tools = registries
242 .tools
243 .tool_ids()
244 .into_iter()
245 .filter_map(|id| {
246 registries.tools.get_tool(&id).map(|tool| {
247 let descriptor = tool.descriptor();
248 let source = classify_tool_source(&descriptor.id);
253 json!({
254 "id": descriptor.id,
255 "name": descriptor.name,
256 "description": descriptor.description,
257 "source": source,
258 })
259 })
260 })
261 .collect::<Vec<_>>();
262
263 let plugins = registries
264 .plugins
265 .plugin_ids()
266 .into_iter()
267 .filter_map(|id| {
268 registries.plugins.get_plugin(&id).map(|plugin| {
269 let schemas = plugin
270 .config_schemas()
271 .into_iter()
272 .map(|schema| {
273 json!({
274 "key": schema.key,
275 "schema": schema.json_schema,
276 "display_name": schema.display_name,
277 "description": schema.description,
278 "category": schema.category,
279 "editor": schema.editor,
280 "default_value": schema.default_value,
281 "ui_schema": schema.ui_schema,
282 })
283 })
284 .collect::<Vec<_>>();
285 json!({
286 "id": plugin.descriptor().name,
287 "config_schemas": schemas,
288 })
289 })
290 })
291 .collect::<Vec<_>>();
292
293 let mut model_ids = registries.models.model_ids();
301 model_ids.sort();
302 let models = model_ids
303 .iter()
304 .filter_map(|id| registries.models.get_model(id))
305 .map(|model| serde_json::to_value(&model))
306 .collect::<Result<Vec<_>, _>>()
307 .map_err(|error| {
308 ConfigServiceError::Apply(format!("failed to serialize model spec: {error}"))
309 })?;
310 let admin_assistant_config = crate::admin_assistant::load_config(&self.state.config)
311 .await
312 .map_err(|error| ConfigServiceError::Apply(error.to_string()))?;
313 let admin_assistant_model_id = crate::admin_assistant::resolve_admin_assistant_model_id(
314 registries.models.as_ref(),
315 registries.providers.as_ref(),
316 admin_assistant_config.model_id.as_deref(),
317 );
318
319 let providers = registries
320 .providers
321 .provider_ids()
322 .into_iter()
323 .map(|id| json!({ "id": id }))
324 .collect::<Vec<_>>();
325
326 let mut backend_ids = registries.backends.backend_ids();
327 backend_ids.sort();
328 let mut backends = vec![backend_schema_json(
329 AWAKEN_BACKEND_KIND,
330 awaken_runtime::awaken_backend_config_schema(),
331 )];
332 backends.extend(backend_ids.into_iter().filter_map(|kind| {
333 if kind == AWAKEN_BACKEND_KIND {
334 return None;
335 }
336 registries
337 .backends
338 .get_backend_factory(&kind)
339 .map(|factory| backend_schema_json(factory.backend(), factory.config_schema()))
340 }));
341
342 let skills = self
343 .state
344 .config
345 .skill_catalog_provider
346 .as_ref()
347 .map(|provider| provider.list_skills())
348 .unwrap_or_default();
349
350 Ok(json!({
351 "agents": self.state.run.resolver.agent_ids(),
352 "tools": tools,
353 "plugins": plugins,
354 "skills": skills,
355 "models": models,
356 "providers": providers,
357 "backends": backends,
358 "admin_assistant": crate::admin_assistant::admin_assistant_capability(
359 &model_ids,
360 admin_assistant_model_id,
361 ),
362 "supported_adapters": super::config_runtime::supported_adapters(),
363 "namespaces": [
364 { "namespace": "agents", "schema": ConfigNamespace::Agents.schema_json()? },
365 { "namespace": "models", "schema": ConfigNamespace::Models.schema_json()? },
366 { "namespace": "model-pools", "schema": ConfigNamespace::ModelPools.schema_json()? },
367 { "namespace": "providers", "schema": ConfigNamespace::Providers.schema_json()? },
368 { "namespace": "mcp-servers", "schema": ConfigNamespace::McpServers.schema_json()? },
369 { "namespace": SKILLS_NAMESPACE, "schema": ConfigNamespace::Skills.schema_json()? },
370 { "namespace": TOOLS_NAMESPACE, "schema": tool_schema_json()? }
371 ],
372 }))
373 }
374
375 pub async fn list(
376 &self,
377 namespace: ConfigNamespace,
378 offset: usize,
379 limit: usize,
380 ) -> Result<Vec<Value>, ConfigServiceError> {
381 let values = self.store.list(namespace.as_str(), offset, limit).await?;
382 values
383 .into_iter()
384 .map(|(_, value)| self.redact_response(namespace, effective_spec(namespace, value)?))
385 .collect()
386 }
387
388 pub async fn get(
389 &self,
390 namespace: ConfigNamespace,
391 id: &str,
392 ) -> Result<Option<Value>, ConfigServiceError> {
393 let value = self.store.get(namespace.as_str(), id).await?;
394 value
395 .map(|value| self.redact_response(namespace, effective_spec(namespace, value)?))
396 .transpose()
397 }
398
399 pub(crate) async fn list_tools(
400 &self,
401 offset: usize,
402 limit: usize,
403 ) -> Result<Vec<Value>, ConfigServiceError> {
404 let values = self.store.list(TOOLS_NAMESPACE, offset, limit).await?;
405 values
406 .into_iter()
407 .map(|(_, value)| effective_tool_spec(value))
408 .collect()
409 }
410
411 pub(crate) async fn get_tool(&self, id: &str) -> Result<Option<Value>, ConfigServiceError> {
412 self.store
413 .get(TOOLS_NAMESPACE, id)
414 .await?
415 .map(effective_tool_spec)
416 .transpose()
417 }
418
419 pub async fn get_meta(
423 &self,
424 namespace: ConfigNamespace,
425 id: &str,
426 ) -> Result<Option<awaken_server_contract::RecordMeta>, ConfigServiceError> {
427 let value = self.store.get(namespace.as_str(), id).await?;
428 let Some(value) = value else {
429 return Ok(None);
430 };
431 let meta = awaken_server_contract::ConfigRecord::<Value>::from_value(value)
434 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
435 .meta;
436 Ok(Some(meta))
437 }
438
439 pub(crate) async fn get_tool_meta(
440 &self,
441 id: &str,
442 ) -> Result<Option<awaken_server_contract::RecordMeta>, ConfigServiceError> {
443 let value = self.store.get(TOOLS_NAMESPACE, id).await?;
444 let Some(value) = value else {
445 return Ok(None);
446 };
447 let meta = awaken_server_contract::ConfigRecord::<Value>::from_value(value)
448 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
449 .meta;
450 Ok(Some(meta))
451 }
452
453 pub async fn list_meta(
456 &self,
457 namespace: ConfigNamespace,
458 offset: usize,
459 limit: usize,
460 ) -> Result<Vec<(String, awaken_server_contract::RecordMeta)>, ConfigServiceError> {
461 let values = self.store.list(namespace.as_str(), offset, limit).await?;
462 let mut out = Vec::with_capacity(values.len());
463 for (id, value) in values {
464 let meta = awaken_server_contract::ConfigRecord::<Value>::from_value(value)
465 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
466 .meta;
467 out.push((id, meta));
468 }
469 Ok(out)
470 }
471
472 pub(crate) async fn list_tool_meta(
473 &self,
474 offset: usize,
475 limit: usize,
476 ) -> Result<Vec<(String, awaken_server_contract::RecordMeta)>, ConfigServiceError> {
477 let values = self.store.list(TOOLS_NAMESPACE, offset, limit).await?;
478 let mut out = Vec::with_capacity(values.len());
479 for (id, value) in values {
480 let meta = awaken_server_contract::ConfigRecord::<Value>::from_value(value)
481 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
482 .meta;
483 out.push((id, meta));
484 }
485 Ok(out)
486 }
487
488 pub async fn validate(
497 &self,
498 namespace: ConfigNamespace,
499 path_id: Option<&str>,
500 body: Value,
501 ) -> Result<Value, ConfigServiceError> {
502 let (id, normalized) = self.prepare_body(namespace, path_id, body).await?;
503 if let Some(path_id) = path_id
504 && path_id != id
505 {
506 return Err(ConfigServiceError::InvalidPayload(format!(
507 "path id '{path_id}' does not match body id '{id}'"
508 )));
509 }
510 self.validate_payload(namespace, &normalized)?;
511 self.redact_response(namespace, normalized)
515 }
516
517 pub async fn create(
518 &self,
519 namespace: ConfigNamespace,
520 body: Value,
521 ) -> Result<Value, ConfigServiceError> {
522 self.create_with_headers(namespace, body, &HeaderMap::new())
523 .await
524 }
525
526 pub async fn create_with_headers(
527 &self,
528 namespace: ConfigNamespace,
529 body: Value,
530 headers: &HeaderMap,
531 ) -> Result<Value, ConfigServiceError> {
532 let manager = self.runtime_manager()?;
533 let _apply_guard = manager.lock_apply().await;
534 let (id, body) = self.prepare_body(namespace, None, body).await?;
535 if self.store.exists(namespace.as_str(), &id).await? {
536 return Err(ConfigServiceError::Conflict(format!(
537 "{}/{} already exists",
538 namespace.as_str(),
539 id
540 )));
541 }
542
543 let result = self
544 .persist_and_apply_locked(
545 manager.as_ref(),
546 namespace,
547 &id,
548 None,
549 body.clone(),
550 headers,
551 )
552 .await?;
553
554 self.emit_audit(
555 AuditAction::Create,
556 namespace,
557 &id,
558 None,
559 Some(body),
560 headers,
561 )
562 .await;
563
564 Ok(result)
565 }
566
567 pub async fn update(
568 &self,
569 namespace: ConfigNamespace,
570 id: &str,
571 body: Value,
572 ) -> Result<Value, ConfigServiceError> {
573 self.update_with_headers(namespace, id, body, &HeaderMap::new())
574 .await
575 }
576
577 pub async fn update_with_headers(
578 &self,
579 namespace: ConfigNamespace,
580 id: &str,
581 body: Value,
582 headers: &HeaderMap,
583 ) -> Result<Value, ConfigServiceError> {
584 let manager = self.runtime_manager()?;
585 let _apply_guard = manager.lock_apply().await;
586 let (body_id, body) = self.prepare_body(namespace, Some(id), body).await?;
587 if body_id != id {
588 return Err(ConfigServiceError::InvalidPayload(format!(
589 "path id '{id}' does not match body id '{body_id}'"
590 )));
591 }
592
593 let previous = self.store.get(namespace.as_str(), id).await?;
594 let result = self
595 .persist_and_apply_locked(
596 manager.as_ref(),
597 namespace,
598 id,
599 previous.clone(),
600 body.clone(),
601 headers,
602 )
603 .await?;
604
605 self.emit_audit(
606 AuditAction::Update,
607 namespace,
608 id,
609 previous.map(unwrap_spec),
610 Some(body),
611 headers,
612 )
613 .await;
614
615 Ok(result)
616 }
617
618 pub async fn delete(
619 &self,
620 namespace: ConfigNamespace,
621 id: &str,
622 ) -> Result<(), ConfigServiceError> {
623 self.delete_with_options(namespace, id, false, &HeaderMap::new())
624 .await
625 }
626
627 pub async fn delete_with_options(
628 &self,
629 namespace: ConfigNamespace,
630 id: &str,
631 force: bool,
632 headers: &HeaderMap,
633 ) -> Result<(), ConfigServiceError> {
634 let manager = self.runtime_manager()?;
635 let _apply_guard = manager.lock_apply().await;
636 let previous = self
637 .store
638 .get(namespace.as_str(), id)
639 .await?
640 .ok_or_else(|| {
641 ConfigServiceError::NotFound(format!("{}/{}", namespace.as_str(), id))
642 })?;
643
644 let provider_force = force && matches!(namespace, ConfigNamespace::Providers);
645 if !provider_force {
646 let blockers = self.find_dependents(namespace, id).await?;
647 if !blockers.is_empty() {
648 return Err(blocked_by_dependents(blockers));
649 }
650 }
651
652 let cascade_model_ids = if provider_force {
653 let provider_models = self.find_dependents(ConfigNamespace::Providers, id).await?;
654 let model_ids = provider_models
655 .into_iter()
656 .map(|model_ref| model_ref.id)
657 .collect::<Vec<_>>();
658 let agent_blockers = self
659 .agents_referencing_models(&model_ids)
660 .await?
661 .into_iter()
662 .map(|agent_id| DependentRef {
663 namespace: "agents",
664 id: agent_id,
665 })
666 .collect::<Vec<_>>();
667 if !agent_blockers.is_empty() {
668 return Err(blocked_by_dependents(agent_blockers));
669 }
670 model_ids
671 } else {
672 Vec::new()
673 };
674
675 let mut records_to_delete: Vec<(ConfigNamespace, String, Value, u64)> = Vec::new();
676 for model_id in cascade_model_ids {
677 let raw = self
678 .store
679 .get(ConfigNamespace::Models.as_str(), &model_id)
680 .await?
681 .ok_or_else(|| ConfigServiceError::NotFound(format!("models/{model_id}")))?;
682 let revision = ConfigRecord::<Value>::from_value(raw.clone())
683 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
684 .meta
685 .revision;
686 records_to_delete.push((ConfigNamespace::Models, model_id, raw, revision));
687 }
688
689 let expected_revision = ConfigRecord::<Value>::from_value(previous.clone())
690 .map_err(|e| ConfigServiceError::Serialization(e.to_string()))?
691 .meta
692 .revision;
693 records_to_delete.push((
694 namespace,
695 id.to_string(),
696 previous.clone(),
697 expected_revision,
698 ));
699
700 let mut deleted_records: Vec<(ConfigNamespace, String, Value, u64)> = Vec::new();
701 for (delete_namespace, delete_id, raw, revision) in records_to_delete {
702 if let Err(error) = self
703 .cas_delete_record(delete_namespace, &delete_id, revision)
704 .await
705 {
706 self.rollback_deleted_records(deleted_records).await?;
707 return Err(error);
708 }
709 deleted_records.push((delete_namespace, delete_id, raw, revision));
710 }
711
712 let apply_result = manager
713 .apply_locked()
714 .await
715 .map(|_| ())
716 .map_err(map_runtime_error);
717 if let Err(error) = apply_result {
718 self.emit_audit_apply_failed(
719 namespace,
720 id,
721 "",
722 Some(unwrap_spec(previous.clone())),
723 None,
724 error.to_string(),
725 headers,
726 )
727 .await;
728 self.rollback_deleted_records(deleted_records).await?;
729 return Err(error);
730 }
731
732 for (deleted_namespace, deleted_id, raw, _) in deleted_records {
733 self.emit_audit(
734 AuditAction::Delete,
735 deleted_namespace,
736 &deleted_id,
737 Some(unwrap_spec(raw)),
738 None,
739 headers,
740 )
741 .await;
742 }
743
744 Ok(())
745 }
746}
747
748fn backend_schema_json(kind: &str, schema: awaken_runtime::ExecutionBackendConfigSchema) -> Value {
749 json!({
750 "kind": kind,
751 "version": schema.version,
752 "schema": schema.schema,
753 "display_name": schema.display_name,
754 "description": schema.description,
755 "default_config": schema.default_config,
756 "ui_schema": schema.ui_schema,
757 })
758}
759
760pub(super) fn map_runtime_error(error: ConfigRuntimeError) -> ConfigServiceError {
761 match error {
762 ConfigRuntimeError::UnsupportedProviderAdapter(_)
763 | ConfigRuntimeError::InvalidConfig(_) => {
764 ConfigServiceError::InvalidPayload(error.to_string())
765 }
766 ConfigRuntimeError::RuntimeNotConfigurable
767 | ConfigRuntimeError::PartialBootstrap
768 | ConfigRuntimeError::PeriodicRefresh(_)
769 | ConfigRuntimeError::ChangeListener(_)
770 | ConfigRuntimeError::VersionedRegistry(_) => ConfigServiceError::Apply(error.to_string()),
771 ConfigRuntimeError::Storage(error) => ConfigServiceError::Storage(error),
772 }
773}
774
775#[cfg(test)]
776mod tests;