Skip to main content

soma_application/
lib.rs

1// Render per-item feature-requirement badges when rustdoc runs on nightly with
2// `--cfg docsrs` (docs.rs posture; locally via `cargo xtask doc --docsrs-cfg`).
3// Inert under the stable CI doc gate: stable rustdoc never sets `docsrs`.
4#![cfg_attr(docsrs, feature(doc_auto_cfg))]
5//! Soma application layer.
6//!
7//! Hosts [`SomaApplication`], the shared use-case facade every surface (MCP,
8//! REST, CLI) calls into, along with the [`SomaService`] business layer, the
9//! provider registry, and the free functions that dispatch actions across
10//! surfaces with consistent observability.
11mod app;
12pub mod capabilities;
13mod context;
14mod error;
15mod ports;
16pub mod provider_errors;
17pub mod provider_registry;
18pub mod providers;
19mod service;
20mod types;
21
22use serde_json::Value;
23use soma_domain::{
24    actions::{action_validation_error, rest_help, SomaAction},
25    errors::{ServiceError, ToolError},
26};
27
28pub use app::SomaApplication;
29pub use context::ExecutionContext;
30pub use error::{ApplicationError, ApplicationErrorDetails};
31pub use ports::{ApplicationPorts, CodeModePort, GatewayPort, OpenApiPort, PortError};
32pub use provider_errors::ProviderError;
33pub use provider_registry::{
34    DynamicResourceTemplate, ProviderAuthMode, ProviderCall, ProviderOutput, ProviderPrincipal,
35    ProviderRegistry, ProviderRequestLimits, ProviderSurface, RegistrySnapshot, ResourceReadOutput,
36};
37pub use providers::filesystem::FileProviderSource;
38pub use providers::remote::RemoteCatalogProvider;
39pub use providers::static_rust::StaticRustProvider;
40pub use service::{
41    ElicitedNameOutcome, ScaffoldIntent, ScaffoldIntentValidationError, SomaService,
42};
43pub use types::CodeModeExecuteRequest;
44pub use types::{
45    CatalogSnapshot, DoctorReport, ElicitedName, ExecuteActionRequest, ExecuteActionResponse,
46    GatewayExecuteRequest, GatewayPromptRoute, GatewayReloadRequest, GatewayResourceRoute,
47    GatewayRouteScope, GatewayToolRoute, OpenApiExecuteRequest, OperationResponse,
48    ReadResourceRequest, ResourceContent, ResourceTemplateSpec, ScaffoldIntentRequest,
49};
50
51pub use soma_provider_core::{ProviderPrompt, ProviderResource};
52
53/// Unified dispatch seam shared by every surface (MCP, REST, CLI).
54///
55/// Wraps [`execute_service_action`] with consistent timing, structured logging,
56/// and metrics so each surface gets identical observability for free. The shims
57/// call this instead of `execute_service_action` directly; `execute_service_action`
58/// remains public for callers that have already established their own span.
59///
60/// `surface` is a short, low-cardinality label such as `"mcp"`, `"rest"`, or
61/// `"cli"`. Action *parameters* are intentionally never logged or labelled —
62/// they can carry credentials, and per-value labels would explode metric
63/// cardinality.
64pub async fn dispatch_action(
65    service: &SomaService,
66    action: &SomaAction,
67    surface: &str,
68) -> anyhow::Result<Value> {
69    let action_name = action.name();
70    let started = std::time::Instant::now();
71    let result = execute_service_action(service, action).await;
72    let elapsed_ms = started.elapsed().as_millis();
73    let outcome = if result.is_ok() { "ok" } else { "error" };
74
75    tracing::info!(
76        surface,
77        service = "soma",
78        action = action_name,
79        outcome,
80        elapsed_ms = elapsed_ms as u64,
81        "action dispatched"
82    );
83    record_action_metric(surface, action_name, outcome, elapsed_ms as f64);
84
85    result
86}
87
88/// Build a registry backed solely by the built-in [`StaticRustProvider`].
89pub fn static_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
90    ProviderRegistry::new(vec![std::sync::Arc::new(StaticRustProvider::new(service))])
91        .map_err(|error| anyhow::anyhow!(error.to_string()))
92}
93
94/// Build a registry combining the static provider with file-backed providers
95/// loaded from the default provider directory (`SOMA_PROVIDER_DIR` or `providers`).
96pub fn dynamic_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
97    dynamic_provider_registry_from_dir(service, default_provider_dir())
98}
99
100/// Build a registry combining the static provider with file-backed providers
101/// loaded from the given directory.
102pub fn dynamic_provider_registry_from_dir(
103    service: SomaService,
104    provider_dir: impl Into<std::path::PathBuf>,
105) -> anyhow::Result<ProviderRegistry> {
106    ProviderRegistry::with_file_source(
107        vec![std::sync::Arc::new(StaticRustProvider::new(service))],
108        crate::capabilities::CapabilityBroker::default_deny(),
109        FileProviderSource::new(provider_dir),
110    )
111    .map_err(|error| anyhow::anyhow!(error.to_string()))
112}
113
114/// Build a registry of [`RemoteCatalogProvider`]s from a remote server's
115/// live provider catalog inspection.
116pub async fn remote_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
117    let report = service.provider_catalog().await?;
118    let providers = providers::remote::catalogs_from_inspection(&report)?
119        .into_iter()
120        .map(|catalog| {
121            std::sync::Arc::new(RemoteCatalogProvider::new(service.clone(), catalog))
122                as std::sync::Arc<dyn provider_registry::Provider>
123        })
124        .collect::<Vec<_>>();
125    ProviderRegistry::new(providers).map_err(|error| anyhow::anyhow!(error.to_string()))
126}
127
128fn default_provider_dir() -> std::path::PathBuf {
129    std::env::var_os("SOMA_PROVIDER_DIR")
130        .map(std::path::PathBuf::from)
131        .unwrap_or_else(|| std::path::PathBuf::from("providers"))
132}
133
134#[cfg(feature = "observability")]
135fn record_action_metric(surface: &str, action: &str, outcome: &str, elapsed_ms: f64) {
136    metrics::counter!(
137        "soma_actions_total",
138        "surface" => surface.to_owned(),
139        "action" => action.to_owned(),
140        "outcome" => outcome.to_owned(),
141    )
142    .increment(1);
143    metrics::histogram!(
144        "soma_action_duration_ms",
145        "surface" => surface.to_owned(),
146        "action" => action.to_owned(),
147    )
148    .record(elapsed_ms);
149}
150
151#[cfg(not(feature = "observability"))]
152fn record_action_metric(_surface: &str, _action: &str, _outcome: &str, _elapsed_ms: f64) {}
153
154/// Route a [`SomaAction`] to the matching [`SomaService`] method.
155///
156/// The lower-level dispatch seam without the timing/logging/metrics wrapper;
157/// prefer [`dispatch_action`] unless the caller already owns a span. Returns an
158/// error for MCP-only actions (`elicit_name`, `scaffold_intent`) that require a peer.
159pub async fn execute_service_action(
160    service: &SomaService,
161    action: &SomaAction,
162) -> anyhow::Result<Value> {
163    match action {
164        SomaAction::Greet { name } => service.greet(name.as_deref()).await,
165        SomaAction::Echo { message } => service.echo(message).await,
166        SomaAction::Status => service.status().await,
167        SomaAction::Help => Ok(rest_help()),
168        SomaAction::ElicitName => Err(anyhow::anyhow!(
169            "action=elicit_name is only available over MCP because it requires a peer"
170        )),
171        SomaAction::ScaffoldIntent => Err(anyhow::anyhow!(
172            "action=scaffold_intent is only available over MCP because it requires elicitation"
173        )),
174    }
175}
176
177/// Report whether the error classifies as a validation failure.
178pub fn is_validation_error(error: &anyhow::Error) -> bool {
179    classify_service_error(error).kind == soma_domain::errors::ServiceErrorKind::Validation
180}
181
182/// Classify an arbitrary error into a structured [`ServiceError`] taxonomy,
183/// recognizing action-validation and scaffold-intent validation failures.
184pub fn classify_service_error(error: &anyhow::Error) -> ServiceError {
185    if let Some(error) = action_validation_error(error) {
186        return ToolError::from_action_validation(error);
187    }
188    if let Some(error) = error.downcast_ref::<ScaffoldIntentValidationError>() {
189        let mut tool_error =
190            ToolError::validation(error.code(), error.to_string(), error.remediation());
191        if let Some(field) = error.field() {
192            tool_error = tool_error.with_field(field);
193        }
194        if let Some(expected_pattern) = error.expected_pattern() {
195            tool_error = tool_error.with_expected_pattern(expected_pattern);
196        }
197        return tool_error;
198    }
199    ToolError::execution(error)
200}