Skip to main content

smooth_operator_server/
local.rs

1//! The **local deployment flavor** — an embeddable, zero-config server.
2//!
3//! This is the third deployment target alongside `deploy/k8s` (Kubernetes +
4//! Postgres + NATS) and `deploy/sst` (AWS serverless): a self-contained server
5//! with **everything in-memory** and **auth off**, meant to run on a developer
6//! laptop or to be **embedded in-process** by a host (e.g. the smooth daemon).
7//!
8//! It needs no external services — no Postgres, no Redis, no NATS, no AWS — and
9//! no secrets. It is exactly the default-flavor server the binary already boots
10//! when no env is set ([`ServerConfig::from_env`](crate::config::ServerConfig::from_env)
11//! defaults to in-memory storage, in-memory backplane, loopback bind, and admin
12//! disabled), factored here so a host can boot it from code in a few lines
13//! rather than by shelling out and setting env vars.
14//!
15//! ## In-process embed
16//!
17//! ```no_run
18//! # async fn demo() -> anyhow::Result<()> {
19//! // Boot a fully in-memory server on the default loopback addr, in the
20//! // background, and get a handle to its real bound address + a shutdown switch.
21//! let server = smooth_operator_server::local::LocalServer::builder()
22//!     .seed_kb(true) // optional: load the demo knowledge docs
23//!     .spawn()
24//!     .await?;
25//!
26//! println!("local operator on ws://{}/ws", server.addr());
27//! // ... use it (connect a client, run turns) ...
28//!
29//! server.shutdown().await; // graceful stop + join
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Run to completion
35//!
36//! ```no_run
37//! # async fn demo() -> anyhow::Result<()> {
38//! // Or just run it in the foreground until killed (what the binary's no-env
39//! // path effectively does):
40//! smooth_operator_server::local::serve_local("127.0.0.1:8787").await?;
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! ## What "local flavor" pins
46//!
47//! Independent of ambient env, the local flavor always uses:
48//!
49//! - **storage** = in-memory ([`InMemoryStorageAdapter`](smooth_operator_adapter_memory::InMemoryStorageAdapter)),
50//! - **backplane** = in-memory (single-process; no Redis/NATS),
51//! - **auth** = none ([`NoAuthVerifier`](smooth_operator::auth::NoAuthVerifier)) — `/admin` is open, `/ws` boots,
52//! - **widget auth** = permissive ([`PermissiveWidgetAuth`](smooth_operator::widget_auth::PermissiveWidgetAuth)),
53//! - **bind** = a caller-supplied addr (default `127.0.0.1:8787`).
54//!
55//! The LLM gateway is still read from `SMOOAI_GATEWAY_URL` / `SMOOAI_GATEWAY_KEY`
56//! (so a key in the environment enables live turns); with no key, `send_message`
57//! returns a clean protocol `error` exactly as the keyless test path does.
58
59use std::net::SocketAddr;
60use std::sync::Arc;
61
62use anyhow::{Context, Result};
63use tokio::net::TcpListener;
64use tokio::task::JoinHandle;
65
66use smooth_operator::auth::NoAuthVerifier;
67
68use crate::config::{ServerConfig, StorageBackend};
69use crate::server::{build_state, router};
70use crate::state::AppState;
71
72/// The default address the local flavor binds when the caller gives none —
73/// loopback on the canonical WebSocket port, matching
74/// [`config::DEFAULT_BIND`](crate::config::DEFAULT_BIND) +
75/// [`config::DEFAULT_PORT`](crate::config::DEFAULT_PORT).
76pub const DEFAULT_LOCAL_ADDR: &str = "127.0.0.1:8787";
77
78/// Builder for the [local deployment flavor](self): a fully in-memory,
79/// auth-off, single-process server, embeddable in-process.
80///
81/// All knobs are optional — `LocalServer::builder().spawn().await` boots the
82/// default flavor (in-memory everything, loopback `:8787`, no auth, no seed).
83/// Construct with [`LocalServer::builder`].
84#[derive(Debug, Clone)]
85pub struct LocalServerBuilder {
86    addr: SocketAddr,
87    seed_kb: bool,
88    config: Option<ServerConfig>,
89}
90
91impl Default for LocalServerBuilder {
92    fn default() -> Self {
93        Self {
94            addr: DEFAULT_LOCAL_ADDR
95                .parse()
96                .expect("DEFAULT_LOCAL_ADDR is a valid SocketAddr"),
97            seed_kb: false,
98            config: None,
99        }
100    }
101}
102
103impl LocalServerBuilder {
104    /// Bind on the given address instead of the default `127.0.0.1:8787`.
105    ///
106    /// Use port `0` for an ephemeral port (read the real one back from
107    /// [`LocalServer::addr`] after [`spawn`](Self::spawn)).
108    #[must_use]
109    pub fn addr(mut self, addr: SocketAddr) -> Self {
110        self.addr = addr;
111        self
112    }
113
114    /// Seed the knowledge base with the demo docs on boot (default `false`).
115    /// Handy for an embedded demo / smoke test with grounded answers.
116    #[must_use]
117    pub fn seed_kb(mut self, seed: bool) -> Self {
118        self.seed_kb = seed;
119        self
120    }
121
122    /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model).
123    ///
124    /// The local flavor still **forces** in-memory storage and the caller's bind
125    /// addr regardless of what this config says — the storage/bind fields are
126    /// overwritten — so the "no external services" guarantee always holds. Use
127    /// this to set the gateway URL / key / model / limits for live turns.
128    #[must_use]
129    pub fn config(mut self, config: ServerConfig) -> Self {
130        self.config = Some(config);
131        self
132    }
133
134    /// Build the [`AppState`] for the local flavor: in-memory storage +
135    /// in-memory backplane (the [`build_state`] defaults) with a no-op
136    /// [`NoAuthVerifier`] explicitly installed so `/admin` is reachable in-process
137    /// without configuring `AUTH_MODE`. The gateway config is honored for live
138    /// turns; with no key, `send_message` errors cleanly.
139    fn build(&self) -> AppState {
140        // Start from the caller's config (or the env-independent defaults) and
141        // pin the local-flavor invariants: in-memory storage + the caller's addr.
142        let mut config = self.config.clone().unwrap_or_else(local_config);
143        config.storage = StorageBackend::Memory;
144        config.bind = self.addr.ip().to_string();
145        config.port = self.addr.port();
146        config.seed_kb = self.seed_kb;
147
148        // `build_state` gives in-memory storage + in-memory backplane + permissive
149        // widget auth. Install the no-op verifier explicitly so the admin API is
150        // reachable in-process without an `AUTH_MODE=none` env handshake.
151        build_state(config).with_auth(Arc::new(NoAuthVerifier::default()))
152    }
153
154    /// Bind and spawn the server in a background task, returning a [`LocalServer`]
155    /// handle carrying the **real** bound address (resolved even for port `0`)
156    /// and a graceful-shutdown switch.
157    ///
158    /// # Errors
159    /// Returns an error if binding the TCP listener fails (e.g. the port is in
160    /// use). Serving errors after a successful bind surface when the handle is
161    /// awaited / shut down.
162    pub async fn spawn(self) -> Result<LocalServer> {
163        let listener = TcpListener::bind(self.addr)
164            .await
165            .with_context(|| format!("binding local smooth-operator server on {}", self.addr))?;
166        let addr = listener.local_addr().context("local addr")?;
167
168        let app = router(self.build());
169        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
170
171        let join = tokio::spawn(async move {
172            axum::serve(listener, app)
173                .with_graceful_shutdown(async move {
174                    // Resolve on an explicit shutdown signal; if the sender is
175                    // dropped (handle gone) we also stop, so we never leak a task.
176                    let _ = shutdown_rx.await;
177                })
178                .await
179                .context("serving local smooth-operator connections")
180        });
181
182        Ok(LocalServer {
183            addr,
184            shutdown_tx: Some(shutdown_tx),
185            join,
186        })
187    }
188}
189
190/// A running [local-flavor](self) server: its bound address + a graceful
191/// shutdown switch.
192///
193/// Dropping the handle without calling [`shutdown`](Self::shutdown) signals the
194/// server to stop (the shutdown channel closes) and detaches the background
195/// task. Call [`shutdown`](Self::shutdown) to stop **and** await a clean exit.
196#[must_use = "the server stops when the handle is dropped; hold it for the server's lifetime"]
197pub struct LocalServer {
198    addr: SocketAddr,
199    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
200    join: JoinHandle<Result<()>>,
201}
202
203impl LocalServer {
204    /// Start building a local-flavor server. See [`LocalServerBuilder`].
205    pub fn builder() -> LocalServerBuilder {
206        LocalServerBuilder::default()
207    }
208
209    /// The real address the server bound on — already resolved, so this returns
210    /// the concrete ephemeral port when the builder asked for port `0`.
211    #[must_use]
212    pub fn addr(&self) -> SocketAddr {
213        self.addr
214    }
215
216    /// The `ws://<addr>/ws` URL clients connect to.
217    #[must_use]
218    pub fn ws_url(&self) -> String {
219        format!("ws://{}/ws", self.addr)
220    }
221
222    /// Signal graceful shutdown and await the server task's clean exit.
223    ///
224    /// # Errors
225    /// Returns an error if the server task panicked or its serve loop errored.
226    pub async fn shutdown(mut self) -> Result<()> {
227        // Trigger graceful shutdown, then await the serve loop.
228        if let Some(tx) = self.shutdown_tx.take() {
229            let _ = tx.send(());
230        }
231        match (&mut self.join).await {
232            Ok(result) => result,
233            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
234        }
235    }
236}
237
238impl Drop for LocalServer {
239    fn drop(&mut self) {
240        // Best-effort: if the handle is dropped without `shutdown`, signal the
241        // serve loop to stop so the background task doesn't outlive the handle.
242        if let Some(tx) = self.shutdown_tx.take() {
243            let _ = tx.send(());
244        }
245    }
246}
247
248/// An [`ServerConfig`] for the local flavor: the env-independent defaults
249/// (loopback bind, default gateway URL/model/limits) with **in-memory storage**
250/// pinned. The gateway URL/key are still read from the environment via
251/// [`ServerConfig::from_env`] so a key present in the host's env enables live
252/// turns; absent, `send_message` errors cleanly.
253#[must_use]
254pub fn local_config() -> ServerConfig {
255    let mut config = ServerConfig::from_env();
256    config.storage = StorageBackend::Memory;
257    config
258}
259
260/// Run a [local-flavor](self) server to completion (blocks) on `addr`.
261///
262/// Convenience for the foreground / one-command case: boots a fully in-memory,
263/// auth-off server bound to `addr` and serves until the process is killed. For
264/// an embedded server you can stop programmatically, use
265/// [`LocalServer::builder`] + [`LocalServer::shutdown`] instead.
266///
267/// # Errors
268/// Returns an error if `addr` doesn't parse, the bind fails, or serving fails.
269pub async fn serve_local(addr: &str) -> Result<()> {
270    let addr: SocketAddr = addr
271        .parse()
272        .with_context(|| format!("parsing local bind address '{addr}'"))?;
273    let server = LocalServer::builder().addr(addr).spawn().await?;
274    let local = server.addr();
275    println!("smooth-operator-server (local flavor) listening on ws://{local}/ws");
276    tracing::info!(%local, endpoint = "/ws", "smooth-operator-server (local flavor) listening");
277
278    // Take ownership of the join handle and await it to completion. We can't
279    // call the consuming `shutdown` here (we want to run forever), so await the
280    // task directly via a small accessor.
281    server.run_to_completion().await
282}
283
284impl LocalServer {
285    /// Await the server task to completion (blocks). Used by [`serve_local`] for
286    /// the run-forever foreground case. The handle is consumed; graceful
287    /// shutdown then only happens on process exit / task error.
288    async fn run_to_completion(mut self) -> Result<()> {
289        // Keep the shutdown sender alive (don't fire it) so the server runs until
290        // the task itself ends (process killed / serve error).
291        match (&mut self.join).await {
292            Ok(result) => result,
293            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
294        }
295        // `self` (and thus `shutdown_tx`) drops here; the loop is already done.
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[tokio::test]
304    async fn spawn_binds_ephemeral_and_reports_real_addr() {
305        // Port 0 → the OS picks a port; the handle must report the real one.
306        let server = LocalServer::builder()
307            .addr("127.0.0.1:0".parse().unwrap())
308            .spawn()
309            .await
310            .expect("spawn local server");
311        let addr = server.addr();
312        assert_ne!(addr.port(), 0, "ephemeral port must be resolved: {addr}");
313        assert!(server.ws_url().starts_with("ws://127.0.0.1:"));
314        server.shutdown().await.expect("clean shutdown");
315    }
316
317    #[tokio::test]
318    async fn build_uses_in_memory_storage_and_no_auth() {
319        let state = LocalServerBuilder::default()
320            .config(ServerConfig {
321                // Even if a caller hands a Postgres config, the local flavor pins
322                // in-memory so the no-external-services guarantee holds.
323                storage: StorageBackend::Postgres,
324                ..local_config()
325            })
326            .build();
327        assert_eq!(state.config.storage, StorageBackend::Memory);
328        // The no-op verifier is installed (admin reachable in-process).
329        assert_eq!(state.auth.mode(), "none");
330    }
331}