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//!
30//! # Auth
31//!
32//! One optional shared-secret bearer, a single-tenant posture.
33//! With a token set on the state, every route requires
34//! `Authorization: Bearer <token>`; without one, the server trusts its caller
35//! and a reverse proxy owns auth. No user model, no RBAC.
36
37#![warn(missing_docs)]
38
39pub mod agents;
40pub mod auth;
41pub mod client_runs;
42pub mod dispatch;
43pub mod error;
44pub mod executor;
45pub mod graph;
46pub mod json;
47pub mod runs;
48pub mod sse;
49pub mod state;
50pub mod tool_registry;
51#[cfg(feature = "ui")]
52pub mod ui;
53
54use axum::Router;
55use axum::middleware::from_fn_with_state;
56use axum::routing::{get, post};
57use tokio::net::TcpListener;
58
59pub use dispatch::{Disposition, ResumeKind, classify};
60pub use error::ApiError;
61pub use executor::{LlmModelExecutor, ModelExecutor, ModelStream};
62pub use state::{
63 AgentDefinition, AgentFactory, AppState, BuildFuture, BuiltAgent, ClientRunLease, DefFormat,
64 RegisteredAgent,
65};
66pub use tool_registry::ToolRegistry;
67
68/// Builds the control-plane router over `state`, with the bearer-auth layer in
69/// front of every route.
70///
71/// With the `ui` feature on, the embedded dashboard is served from the router's
72/// fallback, added after the auth layer so a browser can fetch the app shell
73/// and its assets without a bearer token; the `/v1` API keeps the auth it
74/// registered above. The fallback also holds the SPA rule: a non-API,
75/// non-file GET returns `index.html` so a deep link cold-loads.
76pub fn build_router(state: AppState) -> Router {
77 let api = Router::new()
78 .route("/v1/agents", post(agents::register).get(agents::list))
79 .route("/v1/agents/{hash}", get(agents::get))
80 .route("/v1/runs", post(runs::start).get(runs::list))
81 .route("/v1/runs/{id}", get(runs::get))
82 .route("/v1/runs/{id}/replay", get(runs::replay))
83 .route("/v1/runs/{id}/events", get(sse::stream))
84 .route("/v1/runs/{id}/resume", post(runs::resume))
85 .route("/v1/runs/{id}/resolve", post(runs::resolve))
86 .route("/v1/runs/{id}/abandon", post(runs::abandon))
87 .route("/v1/runs/{id}/graph", get(graph::projection))
88 .route("/v1/runs/{id}/fork", post(graph::fork))
89 .route("/v1/runs/{id}/forks", get(graph::forks))
90 .route("/v1/capabilities", get(capabilities))
91 .route("/v1/graphs", post(graph::submit).get(graph::list))
92 .route("/v1/graphs/validate", post(graph::validate_only))
93 .route("/v1/graphs/{hash}", get(graph::get))
94 .route("/v1/graph-runs", post(graph::start_run))
95 .route("/v1/client-runs", post(client_runs::open))
96 .route("/v1/client-runs/{id}/log", get(client_runs::get_log))
97 .route("/v1/client-runs/{id}/events", post(client_runs::append))
98 .route(
99 "/v1/client-runs/{id}/model-step",
100 post(client_runs::model_step),
101 )
102 .route(
103 "/v1/client-runs/{id}/tool-step",
104 post(client_runs::tool_step),
105 )
106 .route("/v1/client-runs/{id}/resolve", post(client_runs::resolve))
107 .layer(from_fn_with_state(state.clone(), auth::require_bearer));
108
109 // The dashboard fallback is added after the auth layer, so it sits outside
110 // it: static assets and the SPA shell answer without a bearer token, while
111 // every `/v1` route above keeps the auth it registered.
112 #[cfg(feature = "ui")]
113 let api = api.fallback(ui::static_handler);
114
115 // A headless build answers the same question rather than a bare 404, which reads as a broken
116 // route: the dashboard is genuinely absent, and the caller needs to know that is the reason.
117 #[cfg(not(feature = "ui"))]
118 let api = api.fallback(|| async {
119 (
120 axum::http::StatusCode::NOT_FOUND,
121 "this salvor build has no dashboard: it was built without the `ui` feature. \
122 The API is at /v1.\n",
123 )
124 });
125
126 api.with_state(state)
127}
128
129/// `GET /v1/capabilities`: what this build of the control plane can do, for a
130/// dashboard to probe before offering a capability-gated action (the Bridge
131/// gates its fork UI on this). Additive and honest: a capability is advertised
132/// only when the feature genuinely exists on this server, so a probe is never a
133/// promise the server cannot keep. This build advertises `fork: true`, the fork
134/// endpoint ([`graph::fork`]).
135///
136/// The sibling `server` object names the exact build serving the response, so
137/// a dashboard can always show precisely what it is talking to:
138/// `server.version` is `env!("CARGO_PKG_VERSION")`, compile-time-constant and
139/// therefore always correct for the running binary; `server.commit` is the
140/// short git hash `build.rs` shells out for at build time, present only when
141/// that build had a `.git` to ask, omitted entirely (never a placeholder)
142/// otherwise, and suffixed `-dirty` when the working tree carried uncommitted
143/// changes at build time.
144async fn capabilities() -> axum::response::Response {
145 let mut server = serde_json::json!({ "version": env!("CARGO_PKG_VERSION") });
146 if let Some(commit) = option_env!("SALVOR_SERVER_GIT_COMMIT") {
147 server["commit"] = serde_json::Value::String(commit.to_owned());
148 }
149 axum::response::IntoResponse::into_response(axum::Json(serde_json::json!({
150 "capabilities": { "fork": true },
151 "server": server,
152 })))
153}
154
155/// Serves the control plane on `listener` until the process ends.
156///
157/// The caller binds the listener (so it may choose `127.0.0.1:0` and read the
158/// assigned port), then hands it here.
159///
160/// # Errors
161///
162/// Propagates any error from the underlying `axum::serve`.
163pub async fn serve(listener: TcpListener, state: AppState) -> std::io::Result<()> {
164 axum::serve(listener, build_router(state)).await
165}