salvor_server/lib.rs
1//! Salvor control plane: an HTTP and server-sent-events server over the
2//! durable runtime.
3//!
4//! This crate is a thin network surface: submit an
5//! agent definition, start a run, stream its events, resume or recover or
6//! resolve it, list and inspect runs. Durability stays where it belongs, in
7//! the one Rust process that owns the event store; the server holds handles
8//! and constructs a [`salvor_runtime::Runtime`] per request, so nothing about
9//! a run lives in the process that a restart would lose. Clients (the v0.3
10//! Python and TypeScript SDKs, the dashboard) are thin because the guarantees
11//! are not theirs to keep.
12//!
13//! The HTTP contract, every route and shape and the event framing, is the
14//! deliverable those clients build against. It is specified in `API.md`
15//! alongside this source, and each handler module documents its own routes.
16//!
17//! # Shape of the crate
18//!
19//! - [`AppState`] is the shared handle: the store, the agent registry, the
20//! run-driver bookkeeping, and the [`AgentFactory`] seam that turns a
21//! submitted definition into a live agent.
22//! - [`build_router`] wires the routes and the auth layer; [`serve`] binds and
23//! runs them.
24//! - [`dispatch`] holds the state-to-verb mapping shared with the CLI, so the
25//! two surfaces agree on what a run's state means.
26//! - The handler modules ([`agents`](crate::agents), [`runs`](crate::runs),
27//! [`sse`](crate::sse)) own their endpoints; [`error::ApiError`] is the one
28//! error envelope they all return.
29//! - [`wake`] is the background sweeper that re-drives runs whose durable timer
30//! has come due, through the same resume path a person's request takes.
31//!
32//! # Auth
33//!
34//! One optional shared-secret bearer, a single-tenant posture.
35//! With a token set on the state, every route requires
36//! `Authorization: Bearer <token>`; without one, the server trusts its caller
37//! and a reverse proxy owns auth. No user model, no RBAC.
38
39#![warn(missing_docs)]
40
41pub mod agents;
42pub mod auth;
43pub mod client_runs;
44pub mod client_tools;
45pub mod dispatch;
46pub mod error;
47pub mod executor;
48pub mod graph;
49pub mod json;
50pub mod runs;
51pub mod sse;
52pub mod state;
53pub mod tool_registry;
54#[cfg(feature = "ui")]
55pub mod ui;
56pub mod wake;
57
58use axum::Router;
59use axum::middleware::from_fn_with_state;
60use axum::routing::{get, post};
61use tokio::net::TcpListener;
62
63pub use client_tools::{ClientToolDecl, ClientToolRegistry};
64pub use dispatch::{Disposition, ResumeKind, classify};
65pub use error::ApiError;
66pub use executor::{LlmModelExecutor, ModelExecutor, ModelStream};
67pub use state::{
68 AgentDefinition, AgentFactory, AppState, BuildFuture, BuiltAgent, ClientRunLease,
69 DEFAULT_WAKE_INTERVAL, DefFormat, LeaseRelease, RegisteredAgent,
70};
71pub use tool_registry::ToolRegistry;
72pub use wake::{Sweeper, spawn_sweeper, sweep};
73
74/// Builds the control-plane router over `state`, with the bearer-auth layer in
75/// front of every route.
76///
77/// With the `ui` feature on, the embedded dashboard is served from the router's
78/// fallback, added after the auth layer so a browser can fetch the app shell
79/// and its assets without a bearer token; the `/v1` API keeps the auth it
80/// registered above. The fallback also holds the SPA rule: a non-API,
81/// non-file GET returns `index.html` so a deep link cold-loads.
82pub fn build_router(state: AppState) -> Router {
83 let api = Router::new()
84 .route("/v1/agents", post(agents::register).get(agents::list))
85 .route("/v1/agents/{hash}", get(agents::get))
86 .route("/v1/runs", post(runs::start).get(runs::list))
87 .route("/v1/runs/{id}", get(runs::get))
88 .route("/v1/runs/{id}/replay", get(runs::replay))
89 .route("/v1/runs/{id}/events", get(sse::stream))
90 .route("/v1/runs/{id}/resume", post(runs::resume))
91 .route("/v1/runs/{id}/resolve", post(runs::resolve))
92 .route("/v1/runs/{id}/abandon", post(runs::abandon))
93 .route("/v1/runs/{id}/graph", get(graph::projection))
94 .route("/v1/runs/{id}/fork", post(graph::fork))
95 .route("/v1/runs/{id}/forks", get(graph::forks))
96 .route("/v1/capabilities", get(capabilities))
97 .route("/v1/graphs", post(graph::submit).get(graph::list))
98 .route("/v1/graphs/validate", post(graph::validate_only))
99 .route("/v1/graphs/{hash}", get(graph::get))
100 .route("/v1/graph-runs", post(graph::start_run))
101 .route("/v1/client-tools", get(client_tools::list))
102 .route("/v1/client-runs", post(client_runs::open))
103 .route("/v1/client-runs/{id}/release", post(client_runs::release))
104 .route(
105 "/v1/client-runs/{id}/heartbeat",
106 post(client_runs::heartbeat),
107 )
108 .route("/v1/client-runs/{id}/log", get(client_runs::get_log))
109 .route("/v1/client-runs/{id}/events", post(client_runs::append))
110 .route(
111 "/v1/client-runs/{id}/model-step",
112 post(client_runs::model_step),
113 )
114 .route(
115 "/v1/client-runs/{id}/tool-step",
116 post(client_runs::tool_step),
117 )
118 .route(
119 "/v1/client-runs/{id}/client-tool-intent",
120 post(client_runs::client_tool_intent),
121 )
122 .route(
123 "/v1/client-runs/{id}/client-tool-completion",
124 post(client_runs::client_tool_completion),
125 )
126 .route(
127 "/v1/client-runs/{id}/client-model-intent",
128 post(client_runs::client_model_intent),
129 )
130 .route(
131 "/v1/client-runs/{id}/client-model-completion",
132 post(client_runs::client_model_completion),
133 )
134 .route("/v1/client-runs/{id}/resolve", post(client_runs::resolve))
135 .layer(from_fn_with_state(state.clone(), auth::require_bearer));
136
137 // The dashboard fallback is added after the auth layer, so it sits outside
138 // it: static assets and the SPA shell answer without a bearer token, while
139 // every `/v1` route above keeps the auth it registered.
140 #[cfg(feature = "ui")]
141 let api = api.fallback(ui::static_handler);
142
143 // A headless build answers the same question rather than a bare 404, which reads as a broken
144 // route: the dashboard is genuinely absent, and the caller needs to know that is the reason.
145 #[cfg(not(feature = "ui"))]
146 let api = api.fallback(|| async {
147 (
148 axum::http::StatusCode::NOT_FOUND,
149 "this salvor build has no dashboard: it was built without the `ui` feature. \
150 The API is at /v1.\n",
151 )
152 });
153
154 api.with_state(state)
155}
156
157/// `GET /v1/capabilities`: what this build of the control plane can do, for a
158/// dashboard to probe before offering a capability-gated action (the Bridge
159/// gates its fork UI on this). Additive and honest: a capability is advertised
160/// only when the feature genuinely exists on this server, so a probe is never a
161/// promise the server cannot keep. This build advertises `fork: true`, the fork
162/// endpoint ([`graph::fork`]).
163///
164/// The sibling `server` object names the exact build serving the response, so
165/// a dashboard can always show precisely what it is talking to:
166/// `server.version` is `env!("CARGO_PKG_VERSION")`, compile-time-constant and
167/// therefore always correct for the running binary; `server.commit` is the
168/// short git hash `build.rs` shells out for at build time, present only when
169/// that build had a `.git` to ask, omitted entirely (never a placeholder)
170/// otherwise, and suffixed `-dirty` when the working tree carried uncommitted
171/// changes at build time.
172async fn capabilities() -> axum::response::Response {
173 let mut server = serde_json::json!({ "version": env!("CARGO_PKG_VERSION") });
174 if let Some(commit) = option_env!("SALVOR_SERVER_GIT_COMMIT") {
175 server["commit"] = serde_json::Value::String(commit.to_owned());
176 }
177 axum::response::IntoResponse::into_response(axum::Json(serde_json::json!({
178 "capabilities": { "fork": true },
179 "server": server,
180 })))
181}
182
183/// Serves the control plane on `listener` until the process ends.
184///
185/// The caller binds the listener (so it may choose `127.0.0.1:0` and read the
186/// assigned port), then hands it here.
187///
188/// # Errors
189///
190/// Propagates any error from the underlying `axum::serve`.
191pub async fn serve(listener: TcpListener, state: AppState) -> std::io::Result<()> {
192 // Held for exactly as long as this future lives, so a server that is
193 // dropped or aborted takes its sweeper with it. See [`wake::Sweeper`].
194 let _sweeper = wake::spawn_sweeper(state.clone());
195 axum::serve(listener, build_router(state)).await
196}