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}