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.
11
12// Docs rollout (see `[workspace.lints]` in the root Cargo.toml): this crate has
13// completed its public-API docs pass and holds itself to it. The workspace lint
14// table cannot carry this — `[lints] workspace = true` is all-or-nothing — so the
15// commitment lives here, where it overrides the table's `-A missing_docs`.
16#![warn(missing_docs)]
17mod app;
18pub mod capabilities;
19pub(crate) mod componentize;
20mod context;
21mod error;
22pub mod graduation;
23mod ports;
24pub mod provider_errors;
25pub mod provider_registry;
26pub mod providers;
27mod service;
28mod types;
29
30use serde_json::Value;
31use soma_domain::{
32    actions::{SomaAction, action_validation_error, rest_help},
33    errors::{ServiceError, ToolError},
34};
35
36pub use app::SomaApplication;
37pub use context::ExecutionContext;
38pub use error::{ApplicationError, ApplicationErrorDetails};
39pub use ports::{
40    ApplicationPorts, CodeModePort, GatewayPort, OpenApiPort, PortError, PythonEnvironmentPort,
41    PythonEnvironmentUpdateCandidate,
42};
43pub use provider_errors::ProviderError;
44pub use provider_registry::{
45    DynamicResourceTemplate, ProviderAuthMode, ProviderCall, ProviderOutput, ProviderPrincipal,
46    ProviderRegistry, ProviderRequestLimits, ProviderSurface, RegistrySnapshot, ResourceReadOutput,
47};
48pub use providers::filesystem::{
49    FileProviderSource, PythonProviderEnvironmentPreparer, PythonProviderRuntime,
50    PythonRunnerSelection,
51};
52pub use providers::remote::RemoteCatalogProvider;
53pub use providers::static_rust::StaticRustProvider;
54pub use service::{
55    ElicitedNameOutcome, ScaffoldIntent, ScaffoldIntentValidationError, SomaService,
56};
57pub use soma_provider_adapters::python::host::PythonExecutionProfile;
58pub use soma_provider_adapters::python::supervisor::PythonSupervisorConfig;
59pub use soma_provider_adapters::python::{
60    PythonInterpreter,
61    cache::{PythonEnvironmentCache, PythonEnvironmentPrunePlan, PythonEnvironmentPrunePolicy},
62    environment::{ENVIRONMENT_PLAN_VERSION, PythonRuntimeFingerprint},
63    lifecycle::{PythonEnvironmentLifecycle, PythonEnvironmentSpec},
64    materializer::{
65        PreparedPythonEnvironment, PythonEnvironmentRepairReport, PythonEnvironmentUpdateReport,
66    },
67};
68pub use types::CodeModeExecuteRequest;
69pub use types::{
70    CatalogSnapshot, DoctorReport, ElicitedName, ExecuteActionRequest, ExecuteActionResponse,
71    GatewayExecuteRequest, GatewayMcpOutcome, GatewayMcpRoundTrip, GatewayPromptRoute,
72    GatewayReloadRequest, GatewayResourceRoute, GatewayRouteScope, GatewayToolRoute,
73    OpenApiExecuteRequest, OperationResponse, ReadResourceRequest, ResourceContent,
74    ResourceTemplateSpec, ScaffoldIntentRequest,
75};
76
77/// Configure the durable state path shared by Python and component providers.
78pub fn configure_provider_state_path(path: std::path::PathBuf) -> Result<(), String> {
79    soma_provider_adapters::configure_provider_state_path(path)
80}
81
82pub use soma_provider_core::{CapabilityGrant, ProviderPrompt, ProviderResource};
83
84/// Unified dispatch seam shared by every surface (MCP, REST, CLI).
85///
86/// Wraps [`execute_service_action`] with consistent timing, structured logging,
87/// and metrics so each surface gets identical observability for free. The shims
88/// call this instead of `execute_service_action` directly; `execute_service_action`
89/// remains public for callers that have already established their own span.
90///
91/// `surface` is a short, low-cardinality label such as `"mcp"`, `"rest"`, or
92/// `"cli"`. Action *parameters* are intentionally never logged or labelled —
93/// they can carry credentials, and per-value labels would explode metric
94/// cardinality.
95pub async fn dispatch_action(
96    service: &SomaService,
97    action: &SomaAction,
98    surface: &str,
99) -> anyhow::Result<Value> {
100    let action_name = action.name();
101    let started = std::time::Instant::now();
102    let result = execute_service_action(service, action).await;
103    let elapsed_ms = started.elapsed().as_millis();
104    let outcome = if result.is_ok() { "ok" } else { "error" };
105
106    tracing::info!(
107        surface,
108        service = "soma",
109        action = action_name,
110        outcome,
111        elapsed_ms = elapsed_ms as u64,
112        "action dispatched"
113    );
114    record_action_metric(surface, action_name, outcome, elapsed_ms as f64);
115
116    result
117}
118
119/// Build a registry backed solely by the built-in [`StaticRustProvider`].
120pub fn static_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
121    ProviderRegistry::new(vec![std::sync::Arc::new(StaticRustProvider::new(service))])
122        .map_err(|error| anyhow::anyhow!(error.to_string()))
123}
124
125/// Build a registry combining the static provider with file-backed providers
126/// loaded from the default provider directory (`SOMA_PROVIDER_DIR` or `providers`).
127pub fn dynamic_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
128    dynamic_provider_registry_from_dir(service, default_provider_dir())
129}
130
131/// Builds a dynamic registry with an explicit Python execution strategy.
132pub async fn dynamic_provider_registry_with_python(
133    service: SomaService,
134    python_runner: PythonRunnerSelection,
135) -> anyhow::Result<ProviderRegistry> {
136    dynamic_provider_registry_with_python_runtime(
137        service,
138        PythonProviderRuntime::new(python_runner),
139    )
140    .await
141}
142
143/// Builds a dynamic registry with explicit Python execution and environment preparation.
144pub async fn dynamic_provider_registry_with_python_runtime(
145    service: SomaService,
146    python_runtime: PythonProviderRuntime,
147) -> anyhow::Result<ProviderRegistry> {
148    dynamic_provider_registry_from_dir_with_python_runtime(
149        service,
150        default_provider_dir(),
151        python_runtime,
152    )
153    .await
154}
155
156/// Build a registry combining the static provider with file-backed providers
157/// loaded from the given directory.
158pub fn dynamic_provider_registry_from_dir(
159    service: SomaService,
160    provider_dir: impl Into<std::path::PathBuf>,
161) -> anyhow::Result<ProviderRegistry> {
162    ProviderRegistry::with_file_source(
163        vec![std::sync::Arc::new(StaticRustProvider::new(service))],
164        crate::capabilities::CapabilityBroker::default_deny(),
165        FileProviderSource::new(provider_dir),
166    )
167    .map_err(|error| anyhow::anyhow!(error.to_string()))
168}
169
170/// Builds a dynamic registry from a directory with explicit Python execution.
171pub async fn dynamic_provider_registry_from_dir_with_python(
172    service: SomaService,
173    provider_dir: impl Into<std::path::PathBuf>,
174    python_runner: PythonRunnerSelection,
175) -> anyhow::Result<ProviderRegistry> {
176    dynamic_provider_registry_from_dir_with_python_runtime(
177        service,
178        provider_dir,
179        PythonProviderRuntime::new(python_runner),
180    )
181    .await
182}
183
184/// Builds a dynamic registry from a directory with explicit Python execution
185/// and immutable environment preparation.
186pub async fn dynamic_provider_registry_from_dir_with_python_runtime(
187    service: SomaService,
188    provider_dir: impl Into<std::path::PathBuf>,
189    python_runtime: PythonProviderRuntime,
190) -> anyhow::Result<ProviderRegistry> {
191    let (source, capability_broker) =
192        python_runtime.configure_source(FileProviderSource::new(provider_dir));
193    ProviderRegistry::with_file_source_async(
194        vec![std::sync::Arc::new(StaticRustProvider::new(service))],
195        capability_broker,
196        source,
197    )
198    .await
199    .map_err(|error| anyhow::anyhow!(error.to_string()))
200}
201
202/// Build a registry of [`RemoteCatalogProvider`]s from a remote server's
203/// live provider catalog inspection.
204pub async fn remote_provider_registry(service: SomaService) -> anyhow::Result<ProviderRegistry> {
205    let report = service.provider_catalog().await?;
206    let providers = providers::remote::catalogs_from_inspection(&report)?
207        .into_iter()
208        .map(|catalog| {
209            std::sync::Arc::new(RemoteCatalogProvider::new(service.clone(), catalog))
210                as std::sync::Arc<dyn provider_registry::Provider>
211        })
212        .collect::<Vec<_>>();
213    ProviderRegistry::new(providers).map_err(|error| anyhow::anyhow!(error.to_string()))
214}
215
216fn default_provider_dir() -> std::path::PathBuf {
217    std::env::var_os("SOMA_PROVIDER_DIR")
218        .map(std::path::PathBuf::from)
219        .unwrap_or_else(|| std::path::PathBuf::from("providers"))
220}
221
222#[cfg(feature = "observability")]
223fn record_action_metric(surface: &str, action: &str, outcome: &str, elapsed_ms: f64) {
224    metrics::counter!(
225        "soma_actions_total",
226        "surface" => surface.to_owned(),
227        "action" => action.to_owned(),
228        "outcome" => outcome.to_owned(),
229    )
230    .increment(1);
231    metrics::histogram!(
232        "soma_action_duration_ms",
233        "surface" => surface.to_owned(),
234        "action" => action.to_owned(),
235    )
236    .record(elapsed_ms);
237}
238
239#[cfg(not(feature = "observability"))]
240fn record_action_metric(_surface: &str, _action: &str, _outcome: &str, _elapsed_ms: f64) {}
241
242/// Route a [`SomaAction`] to the matching [`SomaService`] method.
243///
244/// The lower-level dispatch seam without the timing/logging/metrics wrapper;
245/// prefer [`dispatch_action`] unless the caller already owns a span. Returns an
246/// error for MCP-only actions (`elicit_name`, `scaffold_intent`) that require a peer.
247pub async fn execute_service_action(
248    service: &SomaService,
249    action: &SomaAction,
250) -> anyhow::Result<Value> {
251    match action {
252        SomaAction::Greet { name } => service.greet(name.as_deref()).await,
253        SomaAction::Echo { message } => service.echo(message).await,
254        SomaAction::Status => service.status().await,
255        SomaAction::PythonEnvironmentStatus
256        | SomaAction::PythonEnvironmentPrunePlan { .. }
257        | SomaAction::PythonEnvironmentPrune { .. }
258        | SomaAction::PythonEnvironmentRepair { .. }
259        | SomaAction::PythonEnvironmentUpdate { .. }
260        | SomaAction::PythonWorkerStatus
261        | SomaAction::PythonWorkerCancel { .. }
262        | SomaAction::PythonWorkerReset { .. }
263        | SomaAction::PythonGenerationStatus
264        | SomaAction::PythonGenerationRollback { .. }
265        | SomaAction::PythonGraduationStatus { .. }
266        | SomaAction::PythonGraduationApply { .. } => Err(anyhow::anyhow!(
267            "Python operator actions require the shared application control plane"
268        )),
269        SomaAction::Help => Ok(rest_help()),
270        SomaAction::ElicitName => Err(anyhow::anyhow!(
271            "action=elicit_name is only available over MCP because it requires a peer"
272        )),
273        SomaAction::ScaffoldIntent => Err(anyhow::anyhow!(
274            "action=scaffold_intent is only available over MCP because it requires elicitation"
275        )),
276    }
277}
278
279/// Report whether the error classifies as a validation failure.
280pub fn is_validation_error(error: &anyhow::Error) -> bool {
281    classify_service_error(error).kind == soma_domain::errors::ServiceErrorKind::Validation
282}
283
284/// Classify an arbitrary error into a structured [`ServiceError`] taxonomy,
285/// recognizing action-validation and scaffold-intent validation failures.
286pub fn classify_service_error(error: &anyhow::Error) -> ServiceError {
287    if let Some(error) = action_validation_error(error) {
288        return ToolError::from_action_validation(error);
289    }
290    if let Some(error) = error.downcast_ref::<ScaffoldIntentValidationError>() {
291        let mut tool_error =
292            ToolError::validation(error.code(), error.to_string(), error.remediation());
293        if let Some(field) = error.field() {
294            tool_error = tool_error.with_field(field);
295        }
296        if let Some(expected_pattern) = error.expected_pattern() {
297            tool_error = tool_error.with_expected_pattern(expected_pattern);
298        }
299        return tool_error;
300    }
301    ToolError::execution(error)
302}