Skip to main content

serve/
hosting.rs

1//! Hosting targets: run a serve app inside another platform's contract.
2//!
3//! [`start`](crate::start) serves the `/v1` wire API on its own. A hosting
4//! target is a crate of its own (for example `everruns-serve-agentcore` for
5//! Amazon Bedrock AgentCore Runtime) that boots the same host through
6//! [`Server`], adds the platform's routes around [`Server::router`], and
7//! hands every other command back to [`start`](crate::start).
8//!
9//! Decisions:
10//! - Composition, not a trait: a target owns its binary entry and its router,
11//!   and serve exposes only what a target needs (the `/v1` router, a busy
12//!   signal for health checks, the AG-UI run, the schedules). A target needs
13//!   no access to serve's internals, so new targets need no serve change.
14//! - [`Server::new`] boots exactly like `dev` and `start`: `start` refuses
15//!   missing secrets, and every agent is resolved once so a bad model or tool
16//!   schema fails at boot rather than on the first request.
17//! - A target that runs each session in its own microVM supplies the
18//!   `[sandbox] kind = "microvm"` adapter ([`ServerBuilder::microvm`]). Apps
19//!   do not change: the same `serve.toml` gets bashkit under `dev` and the
20//!   real machine under the target.
21//! - Experimental, like the rest of serve.
22
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use anyhow::{anyhow, bail};
27use axum::Router;
28
29use crate::app::{App, Mode};
30use crate::host::Host;
31
32/// What a hosting target supplies for `[sandbox] kind = "microvm"`: it adds
33/// the shell and filesystem to each agent as it is built. Without one,
34/// `microvm` falls back to bashkit.
35pub type MicroVm = Arc<dyn Fn(everruns::AgentBuilder) -> everruns::AgentBuilder + Send + Sync>;
36
37/// Builder for [`Server`].
38#[must_use]
39pub struct ServerBuilder {
40    app: App,
41    mode: Mode,
42    data_dir: Option<PathBuf>,
43    microvm: Option<MicroVm>,
44}
45
46impl ServerBuilder {
47    /// Persist under `dir`. Without it everything stays in memory.
48    pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
49        self.data_dir = Some(dir.into());
50        self
51    }
52
53    /// The `[sandbox] kind = "microvm"` adapter. See [`MicroVm`].
54    pub fn microvm(
55        mut self,
56        f: impl Fn(everruns::AgentBuilder) -> everruns::AgentBuilder + Send + Sync + 'static,
57    ) -> Self {
58        self.microvm = Some(Arc::new(f));
59        self
60    }
61
62    /// Boot. Fails when the mode is [`Mode::Start`] and a declared secret is
63    /// unset, or when an agent does not resolve.
64    pub fn build(self) -> crate::Result<Server> {
65        let Self {
66            app,
67            mode,
68            data_dir,
69            microvm,
70        } = self;
71        if !app.errors().is_empty() {
72            bail!(
73                "{} problem(s) found during discovery: {}",
74                app.errors().len(),
75                app.errors().join("; ")
76            );
77        }
78        let missing = missing_secrets(&app);
79        if mode == Mode::Start && !missing.is_empty() {
80            bail!("missing secrets: {}", missing.join(", "));
81        }
82        let host = Host::new(app.clone(), mode, data_dir)?;
83        if let Some(microvm) = microvm {
84            host.set_microvm(microvm);
85        }
86        // Resolve every agent once so a bad model or tool schema fails at boot.
87        for agent in &app.inner.agents {
88            host.build_agent(agent, None, false)?;
89        }
90        Ok(Server { host })
91    }
92}
93
94/// A booted serve host, for a hosting target to wrap.
95#[derive(Clone)]
96pub struct Server {
97    pub(crate) host: Arc<Host>,
98}
99
100impl Server {
101    /// Start building a server for `app` in `mode`.
102    pub fn builder(app: App, mode: Mode) -> ServerBuilder {
103        ServerBuilder {
104            app,
105            mode,
106            data_dir: None,
107            microvm: None,
108        }
109    }
110
111    /// Boot `app` in `mode`, persisting under `data_dir` (`None` keeps
112    /// everything in memory). Shorthand for [`Server::builder`].
113    pub fn new(app: App, mode: Mode, data_dir: Option<PathBuf>) -> crate::Result<Self> {
114        let builder = Self::builder(app, mode);
115        match data_dir {
116            Some(dir) => builder.data_dir(dir),
117            None => builder,
118        }
119        .build()
120    }
121
122    /// The app this server runs.
123    pub fn app(&self) -> &App {
124        &self.host.app
125    }
126
127    /// The mode it was booted in.
128    pub fn mode(&self) -> Mode {
129        self.host.mode
130    }
131
132    /// The build sessions are pinned to.
133    pub fn build_id(&self) -> &str {
134        &self.host.build_id
135    }
136
137    /// serve's `/v1` wire API (plus `/health`), to merge into a target's
138    /// router.
139    pub fn router(&self) -> Router {
140        crate::server::router(self.host.clone())
141    }
142
143    /// Whether a turn is running right now. A turn parked on an approval or
144    /// a question is not busy: nothing runs until a person answers.
145    pub fn busy(&self) -> bool {
146        self.host.busy()
147    }
148
149    /// The agent a request lands on when it names none: the `default` agent,
150    /// or the only top-level one.
151    pub fn default_agent(&self) -> Option<String> {
152        self.host
153            .app
154            .default_agent()
155            .map(|agent| agent.name.to_string())
156    }
157
158    /// Run the app's `#[schedule]`s in-process, as `dev` and `start` do.
159    pub fn spawn_schedules(&self) {
160        crate::scheduler::spawn(&self.host);
161    }
162
163    /// One AG-UI run of `agent` from a raw `RunAgentInput` JSON body: the
164    /// AG-UI 1.0 event stream, or the problem `POST /v1/channels/{agent}/ag-ui`
165    /// would answer. Requires the `ag-ui` feature.
166    #[cfg(feature = "ag-ui")]
167    pub async fn ag_ui(&self, agent: &str, body: &[u8]) -> axum::response::Response {
168        crate::ag_ui::respond(&self.host, agent, body).await
169    }
170}
171
172/// Declared secrets that are unset or empty in this process.
173pub(crate) fn missing_secrets(app: &App) -> Vec<String> {
174    app.manifest()
175        .secrets
176        .iter()
177        .map(|secret| secret.name.clone())
178        .filter(|name| std::env::var_os(name).is_none_or(|value| value.is_empty()))
179        .collect()
180}
181
182/// Where `dev` and `start` persist: the SQLite path in `DATABASE_URL`, else
183/// `SERVE_DATA_DIR`, else `.serve/`.
184pub fn data_dir() -> crate::Result<PathBuf> {
185    if let Ok(url) = std::env::var("DATABASE_URL") {
186        return match url
187            .strip_prefix("sqlite://")
188            .or_else(|| url.strip_prefix("sqlite:"))
189        {
190            Some(path) => Ok(PathBuf::from(path)),
191            None => Err(anyhow!(
192                "DATABASE_URL `{url}` is not SQLite; this proof of concept stores sessions in SQLite only"
193            )),
194        };
195    }
196    Ok(std::env::var_os("SERVE_DATA_DIR")
197        .map(PathBuf::from)
198        .unwrap_or_else(|| PathBuf::from(".serve")))
199}