Skip to main content

soma_application/
provider_registry.rs

1//! Provider dispatch registry: the [`ProviderRegistry`] resolves actions to
2//! providers, builds catalog snapshots, and indexes resources and prompts.
3use std::{
4    collections::{BTreeMap, HashMap, VecDeque},
5    path::{Path, PathBuf},
6    sync::{
7        Arc, RwLock,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use soma_domain::provider_validation::{ProviderValidationError, validate_provider_manifest};
16use soma_provider_adapters::python::materializer::PreparedPythonEnvironment;
17use soma_provider_core::{
18    ProviderCall as CoreProviderCall, ProviderCatalog, ProviderRegistry as CoreRegistry,
19    ProviderResource, RegistrySnapshot as CoreRegistrySnapshot,
20};
21
22use crate::{
23    capabilities::CapabilityBroker,
24    provider_errors::ProviderError,
25    providers::filesystem::{FileProviderSource, PythonProviderEnvironmentSelections},
26};
27
28mod dispatch_lease;
29mod enforcement;
30mod generations;
31#[path = "provider_registry_map.rs"]
32mod map;
33use map::provider_map;
34#[path = "provider_registry_openapi.rs"]
35mod openapi;
36mod python_operations;
37mod refresh;
38mod reports;
39mod resources;
40use dispatch_lease::DispatchLease;
41pub(super) use enforcement::provider_tool_surface_enabled;
42use enforcement::{enforce_capabilities, enforce_pre_input, enforce_response_limit};
43use generations::{
44    StoredGeneration, publish_generation, settle_transition, settle_transition_async,
45};
46use openapi::openapi_paths_from_core;
47use refresh::{ProviderRefreshEvent, fingerprint_file_source};
48use resources::ResourceIndex;
49pub use resources::{DynamicResourceTemplate, ResourceReadOutput};
50pub use soma_provider_core::{Provider as CoreProvider, ProviderOutput};
51/// Product-neutral provider call shape (`soma_provider_core::ProviderCall`),
52/// as handed to the shared core registry once auth/scope fields are stripped.
53pub type ProviderInvocation = CoreProviderCall;
54
55/// A loaded provider: exposes a catalog and dispatches action calls, with
56/// optional MCP resource-read support layered on top.
57#[async_trait]
58pub trait Provider: Send + Sync {
59    /// The provider's catalog — its tools, prompts, resources, and metadata.
60    fn catalog(&self) -> ProviderCatalog;
61
62    /// Executes one provider action call and returns its output.
63    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError>;
64
65    /// Drains and releases provider-owned runtime resources after a registry
66    /// generation swap.
67    async fn retire(&self) {}
68
69    /// Parks runtime resources while preserving rollback capability.
70    async fn suspend(&self) {}
71
72    /// Returns product-neutral operator status for a stateful runtime.
73    fn runtime_status(&self) -> Option<Value> {
74        None
75    }
76
77    /// Cancels active work without waiting for provider dispatch to complete.
78    fn cancel_active(&self) -> bool {
79        false
80    }
81
82    /// Clears runtime quarantine after explicit operator authorization.
83    async fn reset_quarantine(&self) {}
84
85    /// Stops new work while in-flight work drains on a retained generation.
86    fn deactivate(&self) {}
87
88    /// Re-enables a retained generation selected by operator rollback.
89    fn activate(&self) {}
90
91    /// Reserves an invocation while this provider is still active.
92    fn acquire_dispatch(&self) -> bool {
93        true
94    }
95
96    /// Releases a reservation acquired by [`Provider::acquire_dispatch`].
97    fn release_dispatch(&self) {}
98
99    /// Dynamic resource templates this provider serves. Every provider
100    /// inherits the empty default — only file-based dynamic resource
101    /// readers (`providers/resources/*.ts`) override it.
102    fn dynamic_resource_templates(&self) -> Vec<DynamicResourceTemplate> {
103        Vec::new()
104    }
105
106    /// Whether this provider can actually serve `read_resource` calls.
107    /// `catalog().resources` is a schema-legal field on every provider
108    /// kind's manifest (OpenAPI, MCP, ai-sdk, WASM, Python, generic JSON),
109    /// but only file-based `ResourceFileProvider`s have any mechanism to
110    /// read content back — every other kind inherits `read_resource`'s
111    /// default error. `ResourceIndex::register` uses this to reject a
112    /// manifest that declares resources it can never serve at snapshot-build
113    /// time, rather than letting them appear in `resources/list` and always
114    /// fail `resources/read` with an opaque error.
115    fn supports_resource_reads(&self) -> bool {
116        false
117    }
118
119    /// Reads resource content for a URI the registry has already matched
120    /// against either this provider's `catalog().resources` (exact,
121    /// `params` empty) or one of its `dynamic_resource_templates()`
122    /// (`params` holds captured path parameters).
123    async fn read_resource(
124        &self,
125        uri: &str,
126        params: &BTreeMap<String, String>,
127    ) -> Result<ResourceReadOutput, ProviderError> {
128        let _ = params;
129        Err(ProviderError::validation(
130            &self.catalog().provider.name,
131            uri,
132            "resource_read_not_supported",
133            "this provider does not support resource reads",
134        ))
135    }
136}
137
138/// The Soma-facing surface a provider call arrives on.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum ProviderSurface {
142    /// MCP tool/resource/prompt surface.
143    Mcp,
144    /// REST HTTP surface.
145    Rest,
146    /// Command-line surface.
147    Cli,
148    /// Command palette surface.
149    Palette,
150}
151
152impl ProviderSurface {
153    /// The lowercase string identifier for this surface.
154    pub fn as_str(self) -> &'static str {
155        match self {
156            Self::Mcp => "mcp",
157            Self::Rest => "rest",
158            Self::Cli => "cli",
159            Self::Palette => "palette",
160        }
161    }
162
163    fn core(self) -> soma_provider_core::ProviderSurface {
164        match self {
165            Self::Mcp => soma_provider_core::ProviderSurface::Mcp,
166            Self::Rest => soma_provider_core::ProviderSurface::Rest,
167            Self::Cli => soma_provider_core::ProviderSurface::Cli,
168            Self::Palette => soma_provider_core::ProviderSurface::Palette,
169        }
170    }
171}
172
173/// How the caller authenticated, governing whether scope checks apply.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum ProviderAuthMode {
176    /// Loopback development mode; scope checks bypassed.
177    LoopbackDev,
178    /// Non-loopback behind an authz-enforcing gateway; scope checks bypassed.
179    TrustedGateway,
180    /// Mounted auth middleware; scope checks enforced.
181    Mounted,
182}
183
184/// The authenticated caller: its subject identity and granted scopes.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ProviderPrincipal {
187    /// Subject identifier for the caller.
188    pub subject: String,
189    /// Scopes granted to the caller.
190    pub scopes: Vec<String>,
191}
192
193impl ProviderPrincipal {
194    /// The synthetic principal used in loopback development mode, granted the
195    /// read scope.
196    pub fn loopback_dev() -> Self {
197        Self {
198            subject: "loopback-dev".to_owned(),
199            scopes: vec![soma_domain::actions::READ_SCOPE.to_owned()],
200        }
201    }
202
203    /// An anonymous principal with no scopes.
204    pub fn anonymous() -> Self {
205        Self {
206            subject: "anonymous".to_owned(),
207            scopes: Vec::new(),
208        }
209    }
210}
211
212/// Byte-size ceilings enforced on a provider call's input and response.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct ProviderRequestLimits {
215    /// Maximum accepted request input size in bytes.
216    pub max_input_bytes: usize,
217    /// Maximum permitted response size in bytes.
218    pub max_response_bytes: usize,
219}
220
221impl Default for ProviderRequestLimits {
222    fn default() -> Self {
223        Self {
224            max_input_bytes: 64 * 1024,
225            max_response_bytes: soma_domain::token_limit::MAX_RESPONSE_BYTES,
226        }
227    }
228}
229
230/// A fully-resolved provider action invocation, carrying auth, scope, surface,
231/// and limit context alongside the action and its parameters.
232#[derive(Debug, Clone)]
233pub struct ProviderCall {
234    /// Target provider name.
235    pub provider: String,
236    /// Action to invoke on the provider.
237    pub action: String,
238    /// Action parameters as JSON.
239    pub params: Value,
240    /// Authenticated caller.
241    pub principal: ProviderPrincipal,
242    /// Authentication mode governing scope enforcement.
243    pub auth_mode: ProviderAuthMode,
244    /// Surface the call arrived on.
245    pub surface: ProviderSurface,
246    /// Whether the caller confirmed a destructive action.
247    pub destructive_confirmed: bool,
248    /// Byte-size limits applied to this call.
249    pub limits: ProviderRequestLimits,
250    /// Registry snapshot id this call is bound to.
251    pub snapshot_id: String,
252    /// Stable request identity propagated into isolated provider runtimes.
253    pub request_id: String,
254    /// Optional distributed trace parent propagated after authentication.
255    pub traceparent: Option<String>,
256    /// Optional distributed trace state propagated after authentication.
257    pub tracestate: Option<String>,
258    /// Invocation-scoped progress collector shared with provider adapters.
259    pub progress: soma_provider_core::ProviderProgressReporter,
260}
261
262impl ProviderCall {
263    /// Strips this call down to the product-neutral core invocation shape.
264    pub fn provider_invocation(&self) -> ProviderInvocation {
265        self.provider_invocation_with_progress(self.progress.clone())
266    }
267
268    /// Builds the core invocation with a host-owned progress collector.
269    pub fn provider_invocation_with_progress(
270        &self,
271        progress: soma_provider_core::ProviderProgressReporter,
272    ) -> ProviderInvocation {
273        ProviderInvocation {
274            provider: self.provider.clone(),
275            action: self.action.clone(),
276            params: self.params.clone(),
277            surface: self.surface.core(),
278            snapshot_id: self.snapshot_id.clone(),
279            context: soma_provider_core::ProviderInvocationContext {
280                request_id: self.request_id.clone(),
281                actor_id: (!self.principal.subject.is_empty())
282                    .then(|| self.principal.subject.clone()),
283                actor_scopes: self.principal.scopes.clone(),
284                traceparent: self.traceparent.clone(),
285                tracestate: self.tracestate.clone(),
286                progress,
287            },
288        }
289    }
290
291    /// Builds the serializable execution envelope describing this call.
292    pub fn execution_envelope(&self) -> ProviderExecutionEnvelope {
293        ProviderExecutionEnvelope {
294            schema_version: 1,
295            provider: self.provider.clone(),
296            action: self.action.clone(),
297            params: self.params.clone(),
298            surface: self.surface,
299            snapshot_id: self.snapshot_id.clone(),
300        }
301    }
302
303    /// Serializes the execution envelope to JSON bytes.
304    pub fn execution_payload(&self) -> Result<Vec<u8>, serde_json::Error> {
305        serde_json::to_vec(&self.execution_envelope())
306    }
307}
308
309/// The serializable record of a provider action invocation, emitted for
310/// downstream execution/audit consumers.
311#[derive(Debug, Clone, Serialize)]
312pub struct ProviderExecutionEnvelope {
313    /// Envelope schema version.
314    pub schema_version: u32,
315    /// Target provider name.
316    pub provider: String,
317    /// Action invoked.
318    pub action: String,
319    /// Action parameters as JSON.
320    pub params: Value,
321    /// Surface the call arrived on.
322    pub surface: ProviderSurface,
323    /// Registry snapshot id the call is bound to.
324    pub snapshot_id: String,
325}
326
327/// An immutable, fingerprinted view of the loaded providers: their catalogs,
328/// routing/resource indexes, and cached derived artifacts.
329#[derive(Clone)]
330pub struct RegistrySnapshot {
331    /// Snapshot identifier (equal to the fingerprint).
332    pub id: String,
333    /// Content fingerprint of the loaded provider set.
334    pub fingerprint: String,
335    /// Catalogs for every loaded provider.
336    pub catalogs: Vec<ProviderCatalog>,
337    core: Arc<CoreRegistrySnapshot>,
338    exact_resources: HashMap<String, (String, ProviderResource)>,
339    dynamic_resources: Vec<(String, DynamicResourceTemplate)>,
340    /// Number of JSON schema validators compiled for this snapshot.
341    pub compiled_validator_count: usize,
342    /// Cached, pre-serialized OpenAPI document bytes.
343    pub cached_openapi_bytes: Arc<Vec<u8>>,
344    /// Cached catalog summary JSON.
345    pub cached_catalog_summary: Arc<Value>,
346}
347
348impl RegistrySnapshot {
349    /// The names of every action in this snapshot.
350    pub fn action_names(&self) -> Vec<&str> {
351        self.core.action_names().collect()
352    }
353
354    /// Resolves a REST method+path to its action name, if routed.
355    pub fn route_action(&self, method: &str, path: &str) -> Option<&str> {
356        self.core.route_action(method, path)
357    }
358
359    /// Resolves a CLI command to its action name, if routed.
360    pub fn cli_action(&self, command: &str) -> Option<&str> {
361        self.core.cli_action(command)
362    }
363
364    /// The primitive kind (tool/prompt/resource) for a named primitive.
365    pub fn primitive_kind(&self, name: &str) -> Option<&str> {
366        self.core.primitive_kind(name)
367    }
368
369    /// Whether the action is marked destructive and requires confirmation.
370    pub fn action_requires_confirmation(&self, action: &str) -> bool {
371        self.core
372            .tool(action)
373            .map(|entry| entry.spec().destructive)
374            .unwrap_or(false)
375    }
376
377    /// The provider that owns the given action.
378    pub fn provider_for_action(&self, action: &str) -> Option<&str> {
379        self.core
380            .tool(action)
381            .map(|entry| entry.provider_id().as_str())
382    }
383
384    /// Borrows the underlying product-neutral core snapshot.
385    pub fn core_snapshot(&self) -> &CoreRegistrySnapshot {
386        &self.core
387    }
388
389    pub(crate) fn rest_routes(&self) -> impl Iterator<Item = (&str, &str, &str)> {
390        self.core.rest_routes()
391    }
392}
393
394/// Thread-safe registry of loaded providers backing every Soma surface,
395/// holding the active snapshot and supporting hot-reload from a file source.
396#[derive(Clone)]
397pub struct ProviderRegistry {
398    state: Arc<RwLock<RegistryState>>,
399    refresh_gate: Arc<tokio::sync::Mutex<()>>,
400    refresh_dirty: Arc<AtomicBool>,
401    capabilities: CapabilityBroker,
402    base_providers: Arc<Vec<Arc<dyn Provider>>>,
403    file_source: Option<FileProviderSource>,
404}
405
406struct RegistryState {
407    providers: BTreeMap<String, Arc<dyn Provider>>,
408    core_registry: CoreRegistry,
409    snapshot: Arc<RegistrySnapshot>,
410    file_fingerprint: Option<String>,
411    python_environments: PythonProviderEnvironmentSelections,
412    generation_id: u64,
413    history: VecDeque<StoredGeneration>,
414}
415
416const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(75);
417
418impl ProviderRegistry {
419    /// Returns the configured dynamic-provider root, when file providers are enabled.
420    #[must_use]
421    pub fn file_provider_root(&self) -> Option<&std::path::Path> {
422        self.file_source.as_ref().map(FileProviderSource::root)
423    }
424
425    /// Builds a registry from the given providers with a default-deny
426    /// capability broker.
427    pub fn new(providers: Vec<Arc<dyn Provider>>) -> Result<Self, ProviderValidationError> {
428        Self::with_capabilities(providers, CapabilityBroker::default_deny())
429    }
430
431    /// Builds a registry from the given providers and capability broker.
432    pub fn with_capabilities(
433        providers: Vec<Arc<dyn Provider>>,
434        capabilities: CapabilityBroker,
435    ) -> Result<Self, ProviderValidationError> {
436        let (providers, core_registry, snapshot) = build_registry(providers)?;
437        Ok(Self {
438            state: Arc::new(RwLock::new(RegistryState {
439                providers,
440                core_registry,
441                snapshot,
442                file_fingerprint: None,
443                python_environments: PythonProviderEnvironmentSelections::new(),
444                generation_id: 1,
445                history: VecDeque::new(),
446            })),
447            refresh_gate: Arc::new(tokio::sync::Mutex::new(())),
448            refresh_dirty: Arc::new(AtomicBool::new(false)),
449            capabilities,
450            base_providers: Arc::new(Vec::new()),
451            file_source: None,
452        })
453    }
454
455    /// Builds a registry from the given base providers plus dynamic providers
456    /// loaded from a file source, enabling later hot-reloads.
457    pub fn with_file_source(
458        providers: Vec<Arc<dyn Provider>>,
459        capabilities: CapabilityBroker,
460        file_source: FileProviderSource,
461    ) -> Result<Self, ProviderValidationError> {
462        let file_fingerprint = file_source.fingerprint().map_err(|error| {
463            ProviderValidationError::new("provider_file_load_failed", error.to_string())
464        })?;
465        let dynamic_providers = file_source.load().map_err(|error| {
466            ProviderValidationError::new("provider_file_load_failed", error.to_string())
467        })?;
468        let base_providers = Arc::new(providers);
469        let mut all_providers = base_providers.iter().cloned().collect::<Vec<_>>();
470        all_providers.extend(dynamic_providers);
471        let (providers, core_registry, snapshot) = build_registry(all_providers)?;
472        Ok(Self {
473            state: Arc::new(RwLock::new(RegistryState {
474                providers,
475                core_registry,
476                snapshot,
477                file_fingerprint: Some(file_fingerprint),
478                python_environments: PythonProviderEnvironmentSelections::new(),
479                generation_id: 1,
480                history: VecDeque::new(),
481            })),
482            refresh_gate: Arc::new(tokio::sync::Mutex::new(())),
483            refresh_dirty: Arc::new(AtomicBool::new(false)),
484            capabilities,
485            base_providers,
486            file_source: Some(file_source),
487        })
488    }
489
490    /// Async constructor that preflights persistent Python workers before the
491    /// first registry snapshot becomes visible.
492    pub async fn with_file_source_async(
493        providers: Vec<Arc<dyn Provider>>,
494        capabilities: CapabilityBroker,
495        file_source: FileProviderSource,
496    ) -> Result<Self, ProviderValidationError> {
497        let selections = PythonProviderEnvironmentSelections::new();
498        let file_fingerprint = file_source
499            .fingerprint_with_python_environments(&selections)
500            .map_err(|error| {
501                ProviderValidationError::new("provider_file_load_failed", error.to_string())
502            })?;
503        let dynamic_providers = file_source
504            .load_with_python_environments_async(&selections)
505            .await
506            .map_err(|error| {
507                ProviderValidationError::new("provider_file_load_failed", error.to_string())
508            })?;
509        let base_providers = Arc::new(providers);
510        let mut all_providers = base_providers.iter().cloned().collect::<Vec<_>>();
511        all_providers.extend(dynamic_providers);
512        let (providers, core_registry, snapshot) = build_registry(all_providers)?;
513        Ok(Self {
514            state: Arc::new(RwLock::new(RegistryState {
515                providers,
516                core_registry,
517                snapshot,
518                file_fingerprint: Some(file_fingerprint),
519                python_environments: selections,
520                generation_id: 1,
521                history: VecDeque::new(),
522            })),
523            refresh_gate: Arc::new(tokio::sync::Mutex::new(())),
524            refresh_dirty: Arc::new(AtomicBool::new(false)),
525            capabilities,
526            base_providers,
527            file_source: Some(file_source),
528        })
529    }
530
531    /// Returns the currently active registry snapshot.
532    pub fn snapshot(&self) -> Arc<RegistrySnapshot> {
533        self.state
534            .read()
535            .expect("provider registry lock should not be poisoned")
536            .snapshot
537            .clone()
538    }
539
540    /// Authorize a not-yet-active candidate against the same deployment
541    /// grants used by normal provider dispatch.
542    pub(crate) fn authorize_candidate_capabilities(
543        &self,
544        catalog: &ProviderCatalog,
545        action: &str,
546    ) -> Result<(), ProviderError> {
547        self.capabilities
548            .authorize(&catalog.provider.name, action, &catalog.capabilities)
549    }
550
551    /// Refreshes providers from the file source, if any. Per the drop-in
552    /// provider layout contract ("If a resource disappears or becomes
553    /// invalid, a reload must leave the last valid snapshot active until a
554    /// valid replacement snapshot is available"), a failure anywhere in this
555    /// pipeline (an unreadable directory, a newly-invalid or colliding
556    /// provider file) is logged and this returns the previous snapshot
557    /// rather than propagating the error — one bad drop-in file must not
558    /// take down `list_tools`/`list_prompts`/`read_resource`/etc. for every
559    /// other, unrelated, already-loaded provider.
560    pub fn refresh_file_providers(&self) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
561        let Some(file_source) = &self.file_source else {
562            return Ok(self.snapshot());
563        };
564        let (baseline_fingerprint, active_environments) = {
565            let state = self
566                .state
567                .read()
568                .expect("provider registry lock should not be poisoned");
569            (
570                state.file_fingerprint.clone(),
571                state.python_environments.clone(),
572            )
573        };
574        let file_fingerprint =
575            match file_source.fingerprint_with_python_environments(&active_environments) {
576                Ok(fingerprint) => fingerprint,
577                Err(error) => {
578                    return Ok(self.snapshot_after_refresh_failure(
579                        file_source,
580                        "provider_file_fingerprint_failed",
581                        &error.to_string(),
582                    ));
583                }
584            };
585        if baseline_fingerprint.as_deref() == Some(file_fingerprint.as_str()) {
586            return Ok(self.snapshot());
587        }
588
589        let rebuilt: Result<_, ProviderValidationError> = (|| {
590            let dynamic_providers = file_source
591                .load_with_python_environments(&active_environments)
592                .map_err(|error| {
593                    ProviderValidationError::new("provider_file_load_failed", error.to_string())
594                })?;
595            let mut providers = self.base_providers.iter().cloned().collect::<Vec<_>>();
596            providers.extend(dynamic_providers);
597            build_registry(providers)
598        })();
599        let (providers, core_registry, snapshot) = match rebuilt {
600            Ok(parts) => parts,
601            Err(error) => {
602                return Ok(self.snapshot_after_refresh_failure(
603                    file_source,
604                    error.code(),
605                    error.message(),
606                ));
607            }
608        };
609
610        let mut state = self
611            .state
612            .write()
613            .expect("provider registry lock should not be poisoned");
614        if state.file_fingerprint != baseline_fingerprint
615            || state.python_environments != active_environments
616        {
617            return Ok(state.snapshot.clone());
618        }
619        let previous = state.snapshot.clone();
620        let public_catalog_changed = previous.fingerprint != snapshot.fingerprint;
621        let transition = publish_generation(
622            &mut state,
623            providers,
624            core_registry,
625            snapshot.clone(),
626            Some(file_fingerprint),
627            active_environments,
628        );
629        if public_catalog_changed {
630            ProviderRefreshEvent::new(&previous, &snapshot).log(file_source.root());
631        }
632        let active = state.snapshot.clone();
633        drop(state);
634        settle_transition(transition);
635        Ok(active)
636    }
637
638    /// Refresh variant that preflights persistent Python candidates outside
639    /// the registry write lock before publishing the candidate snapshot.
640    pub async fn refresh_file_providers_async(
641        &self,
642    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
643        let Some(file_source) = &self.file_source else {
644            return Ok(self.snapshot());
645        };
646        // One caller owns candidate preparation. Other request-path refresh
647        // checks keep serving the last healthy generation instead of queueing
648        // behind debounce, catalog import, and old-generation drain.
649        let Ok(_refresh_guard) = self.refresh_gate.try_lock() else {
650            self.refresh_dirty.store(true, Ordering::Release);
651            return Ok(self.snapshot());
652        };
653        let mut active = self.snapshot();
654        for pass in 0..2 {
655            self.refresh_dirty.store(false, Ordering::Release);
656            active = self
657                .refresh_file_providers_owned(file_source, false)
658                .await?;
659            if !self.refresh_dirty.swap(false, Ordering::AcqRel) {
660                break;
661            }
662            if pass == 1 {
663                // Bound request-path work under sustained contention. The
664                // retained dirty bit makes the next caller perform another
665                // refresh instead of forcing this owner to loop forever.
666                self.refresh_dirty.store(true, Ordering::Release);
667            }
668        }
669        Ok(active)
670    }
671
672    /// Acquires the exclusive provider publication lane for a multi-step
673    /// filesystem mutation, strict refresh, verification, and commit.
674    pub(crate) async fn lock_refresh_lane(&self) -> tokio::sync::OwnedMutexGuard<()> {
675        self.refresh_gate.clone().lock_owned().await
676    }
677
678    /// Strictly refreshes while the caller holds [`Self::lock_refresh_lane`].
679    pub(crate) async fn refresh_file_providers_strict_in_lane(
680        &self,
681    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
682        let Some(file_source) = &self.file_source else {
683            return Ok(self.snapshot());
684        };
685        self.refresh_file_providers_owned(file_source, true).await
686    }
687
688    async fn refresh_file_providers_owned(
689        &self,
690        file_source: &FileProviderSource,
691        strict: bool,
692    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
693        let (baseline_fingerprint, active_environments) = {
694            let state = self
695                .state
696                .read()
697                .expect("provider registry lock should not be poisoned");
698            (
699                state.file_fingerprint.clone(),
700                state.python_environments.clone(),
701            )
702        };
703        let observed_fingerprint =
704            match fingerprint_file_source(file_source.clone(), active_environments.clone()).await {
705                Ok(fingerprint) => fingerprint,
706                Err(error) => {
707                    if strict {
708                        return Err(ProviderValidationError::new(
709                            "provider_file_fingerprint_failed",
710                            error.to_string(),
711                        ));
712                    }
713                    return Ok(self.snapshot_after_refresh_failure(
714                        file_source,
715                        "provider_file_fingerprint_failed",
716                        &error.to_string(),
717                    ));
718                }
719            };
720        if baseline_fingerprint.as_deref() == Some(observed_fingerprint.as_str()) {
721            return Ok(self.snapshot());
722        }
723        // Editors commonly emit write/rename/chmod bursts. Hold the single
724        // refresh lane briefly, then fingerprint the settled directory once.
725        tokio::time::sleep(REFRESH_DEBOUNCE).await;
726        let file_fingerprint =
727            match fingerprint_file_source(file_source.clone(), active_environments.clone()).await {
728                Ok(fingerprint) => fingerprint,
729                Err(error) => {
730                    if strict {
731                        return Err(ProviderValidationError::new(
732                            "provider_file_fingerprint_failed",
733                            error.to_string(),
734                        ));
735                    }
736                    return Ok(self.snapshot_after_refresh_failure(
737                        file_source,
738                        "provider_file_fingerprint_failed",
739                        &error.to_string(),
740                    ));
741                }
742            };
743        if baseline_fingerprint.as_deref() == Some(file_fingerprint.as_str()) {
744            return Ok(self.snapshot());
745        }
746        let dynamic_providers = match file_source
747            .load_with_python_environments_async(&active_environments)
748            .await
749        {
750            Ok(providers) => providers,
751            Err(error) => {
752                if strict {
753                    return Err(ProviderValidationError::new(
754                        "provider_file_load_failed",
755                        error.to_string(),
756                    ));
757                }
758                return Ok(self.snapshot_after_refresh_failure(
759                    file_source,
760                    "provider_file_load_failed",
761                    &error.to_string(),
762                ));
763            }
764        };
765        if strict
766            && let Some((provider, status)) = dynamic_providers.iter().find_map(|provider| {
767                provider
768                    .runtime_status()
769                    .filter(|status| status.get("ready").and_then(Value::as_bool) == Some(false))
770                    .map(|status| (provider.catalog().provider.name, status))
771            })
772        {
773            return Err(ProviderValidationError::new(
774                "provider_runtime_not_ready",
775                format!("provider `{provider}` failed runtime preparation: {status}"),
776            ));
777        }
778        let mut all_providers = self.base_providers.iter().cloned().collect::<Vec<_>>();
779        all_providers.extend(dynamic_providers);
780        let (providers, core_registry, snapshot) = match build_registry(all_providers) {
781            Ok(parts) => parts,
782            Err(error) => {
783                if strict {
784                    return Err(error);
785                }
786                return Ok(self.snapshot_after_refresh_failure(
787                    file_source,
788                    error.code(),
789                    error.message(),
790                ));
791            }
792        };
793        let (active, transition) = {
794            let mut state = self
795                .state
796                .write()
797                .expect("provider registry lock should not be poisoned");
798            if state.file_fingerprint != baseline_fingerprint
799                || state.python_environments != active_environments
800            {
801                if strict {
802                    return Err(ProviderValidationError::new(
803                        "provider_refresh_raced",
804                        "provider registry changed during strict refresh",
805                    ));
806                }
807                return Ok(state.snapshot.clone());
808            }
809            let previous = state.snapshot.clone();
810            let public_catalog_changed = previous.fingerprint != snapshot.fingerprint;
811            let transition = publish_generation(
812                &mut state,
813                providers,
814                core_registry,
815                snapshot.clone(),
816                Some(file_fingerprint),
817                active_environments,
818            );
819            if public_catalog_changed {
820                ProviderRefreshEvent::new(&previous, &snapshot).log(file_source.root());
821            }
822            (state.snapshot.clone(), transition)
823        };
824        settle_transition_async(transition).await;
825        Ok(active)
826    }
827
828    /// Validates and atomically activates one immutable Python environment candidate.
829    pub async fn activate_python_candidate(
830        &self,
831        provider_path: &Path,
832        candidate: PreparedPythonEnvironment,
833    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
834        // Explicit updates share the same preparation lane as filesystem
835        // refreshes, preventing duplicate candidate worker construction.
836        let _refresh_guard = self.refresh_gate.lock().await;
837        let file_source = self.file_source.as_ref().ok_or_else(|| {
838            ProviderValidationError::new(
839                "provider_file_source_unavailable",
840                "Python candidates require a file-backed provider registry",
841            )
842        })?;
843        let provider_path = file_source
844            .resolve_python_provider_path(provider_path)
845            .map_err(|error| {
846                ProviderValidationError::new("provider_candidate_path_invalid", error.to_string())
847            })?;
848        let (baseline_fingerprint, baseline_environments) = {
849            let state = self
850                .state
851                .read()
852                .expect("provider registry lock should not be poisoned");
853            (
854                state.file_fingerprint.clone(),
855                state.python_environments.clone(),
856            )
857        };
858        let mut next_environments = baseline_environments.clone();
859        next_environments.insert(provider_path, candidate);
860
861        let dynamic_providers = file_source
862            .load_with_python_environments_async(&next_environments)
863            .await
864            .map_err(|error| {
865                ProviderValidationError::new(
866                    "provider_candidate_validation_failed",
867                    error.to_string(),
868                )
869            })?;
870        let file_fingerprint = file_source
871            .fingerprint_with_python_environments(&next_environments)
872            .map_err(|error| {
873                ProviderValidationError::new("provider_file_fingerprint_failed", error.to_string())
874            })?;
875        let mut providers = self.base_providers.iter().cloned().collect::<Vec<_>>();
876        providers.extend(dynamic_providers);
877        let (providers, core_registry, snapshot) = build_registry(providers)?;
878
879        let (active, transition) = {
880            let mut state = self
881                .state
882                .write()
883                .expect("provider registry lock should not be poisoned");
884            if state.file_fingerprint != baseline_fingerprint
885                || state.python_environments != baseline_environments
886            {
887                return Err(ProviderValidationError::new(
888                    "provider_candidate_activation_raced",
889                    "provider registry changed while the candidate was being validated",
890                ));
891            }
892            let previous = state.snapshot.clone();
893            let public_catalog_changed = previous.fingerprint != snapshot.fingerprint;
894            let transition = publish_generation(
895                &mut state,
896                providers,
897                core_registry,
898                snapshot.clone(),
899                Some(file_fingerprint),
900                next_environments,
901            );
902            if public_catalog_changed {
903                ProviderRefreshEvent::new(&previous, &snapshot).log(file_source.root());
904            }
905            (state.snapshot.clone(), transition)
906        };
907        settle_transition_async(transition).await;
908        Ok(active)
909    }
910
911    fn snapshot_after_refresh_failure(
912        &self,
913        file_source: &FileProviderSource,
914        code: &str,
915        message: &str,
916    ) -> Arc<RegistrySnapshot> {
917        tracing::warn!(
918            root = %file_source.root().display(),
919            code,
920            message,
921            "provider directory refresh failed; keeping the last valid snapshot active"
922        );
923        self.snapshot()
924    }
925
926    /// Builds and validates a snapshot from the given providers without
927    /// installing it, for pre-flight checking a candidate reload.
928    pub fn validate_reload(
929        &self,
930        providers: Vec<Arc<dyn Provider>>,
931    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
932        let (_, _, snapshot) = build_registry(providers)?;
933        Ok(snapshot)
934    }
935
936    /// Atomically replaces the loaded providers and active snapshot,
937    /// clearing any file fingerprint.
938    pub fn reload(
939        &self,
940        providers: Vec<Arc<dyn Provider>>,
941    ) -> Result<Arc<RegistrySnapshot>, ProviderValidationError> {
942        let (providers, core_registry, snapshot) = build_registry(providers)?;
943        let mut state = self
944            .state
945            .write()
946            .expect("provider registry lock should not be poisoned");
947        let transition = publish_generation(
948            &mut state,
949            providers,
950            core_registry,
951            snapshot,
952            None,
953            PythonProviderEnvironmentSelections::new(),
954        );
955        let active = state.snapshot.clone();
956        drop(state);
957        settle_transition(transition);
958        Ok(active)
959    }
960
961    /// Routes a provider call to its owning provider, enforcing pre-input
962    /// checks, declared capabilities, and the response size limit around the
963    /// provider's own `call`.
964    pub async fn dispatch(&self, mut call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
965        let (snapshot, core_registry, lease, tool, capabilities, provider_kind) = {
966            let state = self
967                .state
968                .read()
969                .expect("provider registry lock should not be poisoned");
970            let snapshot = state.snapshot.clone();
971            let entry = snapshot.core_snapshot().tool(&call.action).ok_or_else(|| {
972                ProviderError::validation(
973                    "registry",
974                    call.action.clone(),
975                    "unknown_action",
976                    format!("unknown provider action `{}`", call.action),
977                )
978            })?;
979            let provider_name = entry.provider_id().as_str();
980            let tool = entry.spec().clone();
981            let provider = state.providers.get(provider_name).cloned().ok_or_else(|| {
982                ProviderError::new(
983                    "provider_not_loaded",
984                    provider_name,
985                    Some(entry.spec().name.clone()),
986                    "provider is not loaded in the active registry",
987                    "Reload providers and retry.",
988                )
989            })?;
990            let lease = DispatchLease::acquire(provider).ok_or_else(|| {
991                ProviderError::new(
992                    "provider_generation_draining",
993                    provider_name,
994                    Some(entry.spec().name.clone()),
995                    "provider generation is draining",
996                    "Retry against the active provider generation.",
997                )
998            })?;
999            let (capabilities, provider_kind) = snapshot
1000                .catalogs
1001                .iter()
1002                .find(|catalog| catalog.provider.name == provider_name)
1003                .map(|catalog| (catalog.capabilities.clone(), catalog.provider.kind))
1004                .expect("core provider index must reference an active catalog");
1005            (
1006                snapshot,
1007                state.core_registry.clone(),
1008                lease,
1009                tool,
1010                capabilities,
1011                provider_kind,
1012            )
1013        };
1014
1015        call.provider = lease.provider.catalog().provider.name;
1016        call.snapshot_id = snapshot.id.clone();
1017        let pre_input_call = call.clone();
1018        let invocation_call = call.clone();
1019        let pre_input_tool = tool.clone();
1020        let capability_broker = self.capabilities.clone();
1021        let progress = soma_provider_core::ProviderProgressReporter::default();
1022        let mut output = core_registry
1023            .dispatch_with_pre_input(
1024                call.provider_invocation_with_progress(progress.clone()),
1025                move |invocation| {
1026                    let mut call = pre_input_call;
1027                    call.provider.clone_from(&invocation.provider);
1028                    call.snapshot_id.clone_from(&invocation.snapshot_id);
1029                    enforce_pre_input(&pre_input_tool, &call, provider_kind)
1030                },
1031                move |_, invocation| {
1032                    let mut call = invocation_call;
1033                    call.provider = invocation.provider;
1034                    call.snapshot_id = invocation.snapshot_id;
1035                    call.progress = invocation.context.progress;
1036                    async move {
1037                        enforce_capabilities(&capabilities, &call, &capability_broker)?;
1038                        let output = lease.provider.call(call.clone()).await?;
1039                        enforce_response_limit(&tool, &call, &output)?;
1040                        Ok(output)
1041                    }
1042                },
1043            )
1044            .await
1045            .inspect_err(|error| {
1046                let (provider, action, code) = error.log_code();
1047                tracing::warn!(provider, action, code, "provider call failed");
1048            })?;
1049        output.progress.extend(progress.events());
1050        Ok(output)
1051    }
1052}
1053
1054/// Wraps a product-neutral `soma_provider_core::Provider` (as implemented by
1055/// every adapter in `soma-provider-adapters`) so it satisfies this crate's
1056/// own `Provider` trait, which carries additional auth/scope fields
1057/// (`principal`, `auth_mode`, `destructive_confirmed`, `limits`) that no
1058/// shared adapter reads — see `ProviderCall::provider_invocation()`, which
1059/// this reuses to build the generic call. This is the mirror image of
1060/// `CoreProviderAdapter` below, which wraps the other direction.
1061#[derive(Clone)]
1062pub struct SharedAdapter(Arc<dyn soma_provider_core::Provider>);
1063
1064impl SharedAdapter {
1065    /// Wraps a shared core provider so it satisfies this crate's `Provider`
1066    /// trait.
1067    pub fn wrap(inner: Arc<dyn soma_provider_core::Provider>) -> Arc<dyn Provider> {
1068        Arc::new(Self(inner))
1069    }
1070}
1071
1072#[async_trait]
1073impl Provider for SharedAdapter {
1074    fn catalog(&self) -> ProviderCatalog {
1075        self.0.catalog()
1076    }
1077
1078    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
1079        self.0.call(call.provider_invocation()).await
1080    }
1081
1082    async fn retire(&self) {
1083        self.0.retire().await;
1084    }
1085
1086    async fn suspend(&self) {
1087        self.0.suspend().await;
1088    }
1089
1090    fn runtime_status(&self) -> Option<Value> {
1091        self.0.runtime_status()
1092    }
1093
1094    fn cancel_active(&self) -> bool {
1095        self.0.cancel_active()
1096    }
1097
1098    async fn reset_quarantine(&self) {
1099        self.0.reset_quarantine().await;
1100    }
1101
1102    fn deactivate(&self) {
1103        self.0.deactivate();
1104    }
1105
1106    fn activate(&self) {
1107        self.0.activate();
1108    }
1109
1110    fn acquire_dispatch(&self) -> bool {
1111        self.0.acquire_dispatch()
1112    }
1113
1114    fn release_dispatch(&self) {
1115        self.0.release_dispatch();
1116    }
1117}
1118
1119#[derive(Clone)]
1120struct CoreProviderAdapter(Arc<dyn Provider>);
1121
1122type BuiltRegistry = (
1123    BTreeMap<String, Arc<dyn Provider>>,
1124    CoreRegistry,
1125    Arc<RegistrySnapshot>,
1126);
1127
1128#[async_trait]
1129impl CoreProvider for CoreProviderAdapter {
1130    fn catalog(&self) -> ProviderCatalog {
1131        self.0.catalog()
1132    }
1133
1134    async fn call(&self, call: CoreProviderCall) -> Result<ProviderOutput, ProviderError> {
1135        let surface = match call.surface {
1136            soma_provider_core::ProviderSurface::Mcp => ProviderSurface::Mcp,
1137            soma_provider_core::ProviderSurface::Rest => ProviderSurface::Rest,
1138            soma_provider_core::ProviderSurface::Cli => ProviderSurface::Cli,
1139            soma_provider_core::ProviderSurface::Palette => ProviderSurface::Palette,
1140            soma_provider_core::ProviderSurface::Internal
1141            | soma_provider_core::ProviderSurface::Ui => {
1142                return Err(ProviderError::validation(
1143                    call.provider,
1144                    call.action,
1145                    "unsupported_product_surface",
1146                    "Soma providers expose only MCP, REST, CLI, and Palette surfaces",
1147                ));
1148            }
1149        };
1150        self.0
1151            .call(ProviderCall {
1152                provider: call.provider,
1153                action: call.action,
1154                params: call.params,
1155                principal: ProviderPrincipal::anonymous(),
1156                auth_mode: ProviderAuthMode::TrustedGateway,
1157                surface,
1158                destructive_confirmed: false,
1159                limits: ProviderRequestLimits::default(),
1160                snapshot_id: call.snapshot_id,
1161                request_id: call.context.request_id,
1162                traceparent: call.context.traceparent,
1163                tracestate: call.context.tracestate,
1164                progress: call.context.progress,
1165            })
1166            .await
1167    }
1168
1169    fn acquire_dispatch(&self) -> bool {
1170        self.0.acquire_dispatch()
1171    }
1172
1173    fn release_dispatch(&self) {
1174        self.0.release_dispatch();
1175    }
1176}
1177
1178fn build_registry(
1179    providers: Vec<Arc<dyn Provider>>,
1180) -> Result<BuiltRegistry, ProviderValidationError> {
1181    let providers = provider_map(providers)?;
1182    let mut builder = CoreRegistry::builder();
1183    for provider in providers.values() {
1184        validate_provider_manifest(&provider.catalog())?;
1185        builder = builder.register_arc(Arc::new(CoreProviderAdapter(provider.clone())))?;
1186    }
1187    let core_registry = builder.build()?;
1188    let snapshot = Arc::new(build_snapshot(&providers, &core_registry)?);
1189    Ok((providers, core_registry, snapshot))
1190}
1191
1192fn build_snapshot(
1193    providers: &BTreeMap<String, Arc<dyn Provider>>,
1194    core_registry: &CoreRegistry,
1195) -> Result<RegistrySnapshot, ProviderValidationError> {
1196    let core = core_registry.snapshot();
1197    let mut resources = ResourceIndex::new();
1198    for provider in providers.values() {
1199        let catalog = provider.catalog();
1200        resources.register(&**provider, &catalog.provider.name, &catalog.resources)?;
1201    }
1202    let catalogs = core.catalogs().to_vec();
1203    let fingerprint = core.fingerprint().to_string();
1204    let id = fingerprint.clone();
1205    let action_names = core.action_names().map(str::to_owned).collect::<Vec<_>>();
1206    let openapi_paths = openapi_paths_from_core(&core);
1207    let cached_catalog_summary = Arc::new(json!({
1208        "schema_version": 1,
1209        "provider_fingerprint": fingerprint,
1210        "actions": action_names,
1211    }));
1212    let cached_openapi_bytes = Arc::new(
1213        serde_json::to_vec_pretty(&json!({
1214            "openapi": "3.1.0",
1215            "info": {"title": "soma provider API", "version": env!("CARGO_PKG_VERSION")},
1216            "x-soma": {
1217                "preferred_rest_style": "direct_routes",
1218                "provider_fingerprint": fingerprint
1219            },
1220            "paths": openapi_paths
1221        }))
1222        .expect("static OpenAPI summary serializes"),
1223    );
1224
1225    Ok(RegistrySnapshot {
1226        id,
1227        fingerprint,
1228        catalogs,
1229        core: core.clone(),
1230        exact_resources: resources.exact,
1231        dynamic_resources: resources.dynamic,
1232        compiled_validator_count: core.compiled_validator_count(),
1233        cached_openapi_bytes,
1234        cached_catalog_summary,
1235    })
1236}