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