salvor_server/state.rs
1//! [`AppState`]: the shared handle every request works through, plus the
2//! [`AgentFactory`] seam that turns a submitted definition into a live agent.
3//!
4//! The state owns exactly one thing that matters for durability: an
5//! `Arc<dyn EventStore>`. Everything a request needs, it builds fresh from
6//! that handle. A [`Runtime`] is cheap (a store handle plus two function
7//! pointers), so the server constructs one per request rather than sharing
8//! mutable run state; there is no per-run state living in the process that a
9//! restart would lose. That is the whole kill-safety story restated: the
10//! process holds handles, the store holds truth.
11//!
12//! # Why agent building is a seam, not baked in
13//!
14//! Turning a definition into an [`Agent`] means parsing the agent-definition
15//! format and spawning its MCP servers. That logic already exists in the CLI
16//! (`salvor-cli` owns the TOML schema), and putting a copy here would give the
17//! definition format two homes. Instead the server takes an [`AgentFactory`]:
18//! a caller-supplied function from a submitted [`AgentDefinition`] to a
19//! [`BuiltAgent`]. The `salvor serve` command passes the CLI's own builder, so
20//! there is one definition parser in the workspace; tests pass a factory that
21//! builds an agent with an in-process tool and a mock model, which is how the
22//! control plane is exercised over real HTTP with nothing on the network.
23
24use std::collections::{HashMap, HashSet};
25use std::future::Future;
26use std::pin::Pin;
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use salvor_graph::Graph;
31use salvor_runtime::{Agent, ClockFn, RandomFn, RunCtx, Runtime, RuntimeError};
32use salvor_store::EventStore;
33use salvor_tools::mcp::McpServer;
34use time::OffsetDateTime;
35use tokio::task::JoinHandle;
36
37use salvor_core::{EventEnvelope, RunId};
38
39use crate::executor::ModelExecutor;
40use crate::tool_registry::ToolRegistry;
41
42/// The format a submitted agent definition is written in.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DefFormat {
45 /// The agent TOML the CLI reads from a file.
46 Toml,
47 /// The same definition as a JSON document (what a thin SDK sends).
48 Json,
49}
50
51/// A submitted agent definition: the raw bytes plus the format they are in.
52///
53/// The server never interprets the bytes itself; it hands them to the
54/// [`AgentFactory`]. Keeping the raw body (rather than a parsed structure)
55/// means the definition is rebuilt from exactly what was submitted on every
56/// start, resume, and recover, the same way the CLI rebuilds from the TOML
57/// file each time.
58#[derive(Debug, Clone)]
59pub struct AgentDefinition {
60 /// The format of `body`.
61 pub format: DefFormat,
62 /// The raw definition bytes.
63 pub body: Vec<u8>,
64}
65
66/// A live agent plus the MCP server sessions its tools hold.
67///
68/// The sessions must outlive the run: each MCP tool keeps a client-peer clone
69/// into its server's session, so dropping the sessions stops the tools. The
70/// run driver keeps them for the run's life and closes them when it ends.
71pub struct BuiltAgent {
72 /// The built agent the runtime drives.
73 pub agent: Agent,
74 /// The MCP sessions to keep alive for the run, then close.
75 pub servers: Vec<McpServer>,
76}
77
78/// The future an [`AgentFactory`] returns.
79pub type BuildFuture = Pin<Box<dyn Future<Output = Result<BuiltAgent, String>> + Send>>;
80
81/// Builds a live agent from a submitted definition.
82///
83/// The `Err` is a human message; the register and start handlers turn it into
84/// a `400`, because a definition that will not build is a client mistake.
85pub type AgentFactory = Arc<dyn Fn(AgentDefinition) -> BuildFuture + Send + Sync>;
86
87/// One registered agent definition.
88#[derive(Debug, Clone)]
89pub struct RegisteredAgent {
90 /// The submitted definition, kept for every rebuild.
91 pub definition: AgentDefinition,
92 /// The agent's content hash (`agent_def_hash`), the id clients reference.
93 pub agent_hash: String,
94 /// The agent's display name, when the definition declared one
95 /// (`Agent::name`, read off the built agent at registration time).
96 /// `None` when the definition carried no name — genuinely absent, not a
97 /// default to fall back on: [`agents::get`](crate::agents::get) and
98 /// [`agents::list`](crate::agents::list) omit the field entirely for
99 /// such an agent rather than emit `"name": null`.
100 pub name: Option<String>,
101}
102
103/// The shared, cheaply cloned handle every route works through.
104#[derive(Clone)]
105pub struct AppState {
106 inner: Arc<Inner>,
107}
108
109struct Inner {
110 store: Arc<dyn EventStore>,
111 factory: AgentFactory,
112 // The general model-executor seam the server performs a client-driven run's
113 // model step through. `None` until a host injects one (the `AgentFactory`
114 // pattern): the model-step endpoint then answers with a clear error rather
115 // than performing a call. `salvor serve` wires a default from its own
116 // client-construction path, so the feature works out of the box.
117 model_executor: Option<Arc<dyn ModelExecutor>>,
118 // The general tool-registry seam the server performs a client-driven run's
119 // tool step through. `None` until a host injects one (the same pattern as
120 // `model_executor`): the tool-step endpoint then answers with a clear error
121 // rather than dispatching. `salvor serve` wires an EMPTY registry, so any
122 // tool-step there is a clean `unknown_tool` until a tool is registered.
123 tool_registry: Option<Arc<ToolRegistry>>,
124 hooks: Option<(ClockFn, RandomFn)>,
125 auth_token: Option<String>,
126 poll_interval: Duration,
127 agents: Mutex<HashMap<String, RegisteredAgent>>,
128 // The graph documents this process has accepted, keyed by their reproducible
129 // content hash (`salvor_engine::graph_hash`). In-memory, exactly like the
130 // agent registry above and for the same reason: a graph is pure data with a
131 // content hash, so re-submitting the identical document is idempotent and a
132 // restart re-accepts it under the same hash. Storing it here rather than in
133 // the event store keeps the store schema untouched (additive-migration
134 // discipline) and mirrors how a registered agent lives only in this map.
135 graphs: Mutex<HashMap<String, Graph>>,
136 // Which runs a driver task is still working on, and the handles to those
137 // tasks. The `active` set is membership only, inserted synchronously
138 // before a task is spawned so a concurrent stream can never miss a run
139 // that has just started; `handles` is populated after the spawn and used
140 // only to abort tasks at shutdown, where a stale finished handle is
141 // harmless.
142 active: Mutex<HashSet<RunId>>,
143 handles: Mutex<HashMap<RunId, JoinHandle<()>>>,
144 // The client-driven runs this process has opened, each with its current
145 // drive-token lease. This registry is what keeps the client-driven and
146 // server-driven modes from colliding over one store: the client-driven
147 // endpoints operate only on runs recorded here, so a server-driven run is
148 // never reachable through them, and a foreign run id with existing history
149 // is refused rather than adopted. It is in-memory because the drive token
150 // is a single-writer lease with a process lifetime:
151 // re-opening a run mints a fresh lease.
152 client_runs: Mutex<HashMap<RunId, ClientRunLease>>,
153 // How long a client-driven run's lease stays "current" without the driver
154 // presenting its token again. Past this, the run reports no attached driver
155 // on GET /v1/runs (the client-driven half of the liveness evidence): the tab
156 // closed, the SDK exited, the driver crashed. Generous by default so a single
157 // long model call between drive operations never reads as a false stall; a
158 // test shortens it (see `with_client_lease_ttl`) to prove the lapse.
159 client_lease_ttl: Duration,
160}
161
162/// The per-run lease state for a client-driven run.
163#[derive(Debug, Clone)]
164pub struct ClientRunLease {
165 /// The opaque drive token the single writer must present on every append.
166 pub drive_token: String,
167 /// Whether the opener asked for model request bodies to be recorded. Stored
168 /// at open time; it governs the server-performed model step, not yet
169 /// implemented, and carries no effect on the generic append this surface
170 /// serves.
171 pub record_prompts: bool,
172 /// When the driver last proved it was alive: stamped at open and refreshed
173 /// on every guarded operation (append, model-step, tool-step, resolve), each
174 /// of which presents the drive token. That token is the driver's own proof
175 /// of life, so its arrival IS the heartbeat — there is no separate mechanism.
176 /// Read against the lease TTL to decide whether a driver is still attached to
177 /// a client-driven run (see
178 /// [`client_run_driver_live`](AppState::client_run_driver_live)).
179 pub last_seen: OffsetDateTime,
180}
181
182impl AppState {
183 /// Builds server state over `store`, using `factory` to turn submitted
184 /// definitions into live agents. Auth is off and the clock and random
185 /// source are the runtime defaults until set with the `with_*` methods.
186 #[must_use]
187 pub fn new(store: Arc<dyn EventStore>, factory: AgentFactory) -> Self {
188 Self {
189 inner: Arc::new(Inner {
190 store,
191 factory,
192 model_executor: None,
193 tool_registry: None,
194 hooks: None,
195 auth_token: None,
196 poll_interval: Duration::from_millis(50),
197 agents: Mutex::new(HashMap::new()),
198 graphs: Mutex::new(HashMap::new()),
199 active: Mutex::new(HashSet::new()),
200 handles: Mutex::new(HashMap::new()),
201 client_runs: Mutex::new(HashMap::new()),
202 client_lease_ttl: Duration::from_secs(60),
203 }),
204 }
205 }
206
207 /// Sets how long a client-driven run's lease stays current without the
208 /// driver presenting its token again (default 60s). Past this, the run
209 /// reports no attached driver. Additive and off-default; a test or a seed
210 /// shortens it to make a driverless client run observable quickly, exactly
211 /// as [`with_poll_interval`](Self::with_poll_interval) shortens the stream
212 /// poll. `salvor serve` reads it from `SALVOR_CLIENT_LEASE_TTL_SECS`.
213 #[must_use]
214 pub fn with_client_lease_ttl(mut self, ttl: Duration) -> Self {
215 Arc::get_mut(&mut self.inner)
216 .expect("with_client_lease_ttl is called before the state is shared")
217 .client_lease_ttl = ttl;
218 self
219 }
220
221 /// Requires `Authorization: Bearer <token>` on every request. Without this,
222 /// the server trusts its caller (the reverse-proxy posture).
223 #[must_use]
224 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
225 Arc::get_mut(&mut self.inner)
226 .expect("with_auth_token is called before the state is shared")
227 .auth_token = Some(token.into());
228 self
229 }
230
231 /// Injects the general model executor the server performs a client-driven
232 /// run's model step through. Additive and off by default (the existing
233 /// [`new`](Self::new) leaves it unset), so no caller that predates it
234 /// changes behavior. `salvor serve` wires a default here; another host
235 /// injects its own, exactly as it supplies its own [`AgentFactory`].
236 #[must_use]
237 pub fn with_model_executor(mut self, executor: Arc<dyn ModelExecutor>) -> Self {
238 Arc::get_mut(&mut self.inner)
239 .expect("with_model_executor is called before the state is shared")
240 .model_executor = Some(executor);
241 self
242 }
243
244 /// Injects the general tool registry the server performs a client-driven
245 /// run's tool step through. Additive and off by default (the existing
246 /// [`new`](Self::new) leaves it unset), so no caller that predates it
247 /// changes behavior. `salvor serve` wires an empty registry here; another
248 /// host injects one holding its own tools, exactly as it supplies its own
249 /// [`AgentFactory`] and [`ModelExecutor`](crate::ModelExecutor).
250 #[must_use]
251 pub fn with_tool_registry(mut self, registry: Arc<ToolRegistry>) -> Self {
252 Arc::get_mut(&mut self.inner)
253 .expect("with_tool_registry is called before the state is shared")
254 .tool_registry = Some(registry);
255 self
256 }
257
258 /// Injects the clock and random source every [`Runtime`] this state builds
259 /// uses. Deterministic tests pass fixed functions so full logs compare
260 /// equal across a control run and a recovered one.
261 #[must_use]
262 pub fn with_hooks(mut self, clock: ClockFn, random: RandomFn) -> Self {
263 Arc::get_mut(&mut self.inner)
264 .expect("with_hooks is called before the state is shared")
265 .hooks = Some((clock, random));
266 self
267 }
268
269 /// Sets how often the event stream polls the store for new events (default
270 /// 50ms). Tests shorten it so a streamed run completes quickly.
271 #[must_use]
272 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
273 Arc::get_mut(&mut self.inner)
274 .expect("with_poll_interval is called before the state is shared")
275 .poll_interval = interval;
276 self
277 }
278
279 /// The event store every request reads from and writes through.
280 #[must_use]
281 pub fn store(&self) -> Arc<dyn EventStore> {
282 self.inner.store.clone()
283 }
284
285 /// The expected bearer token, when auth is required.
286 #[must_use]
287 pub fn auth_token(&self) -> Option<&str> {
288 self.inner.auth_token.as_deref()
289 }
290
291 /// How often the event stream polls for new events.
292 #[must_use]
293 pub fn poll_interval(&self) -> Duration {
294 self.inner.poll_interval
295 }
296
297 /// The injected model executor, if a host wired one. `None` means the
298 /// server cannot perform a model step and the endpoint says so.
299 #[must_use]
300 pub fn model_executor(&self) -> Option<Arc<dyn ModelExecutor>> {
301 self.inner.model_executor.clone()
302 }
303
304 /// The injected tool registry, if a host wired one. `None` means the server
305 /// cannot perform a tool step and the endpoint says so; a wired-but-empty
306 /// registry instead reports each tool as `unknown_tool`.
307 #[must_use]
308 pub fn tool_registry(&self) -> Option<Arc<ToolRegistry>> {
309 self.inner.tool_registry.clone()
310 }
311
312 /// Reads the current instant from this state's injected clock, or the real
313 /// UTC clock when none was injected. This stamps envelopes the server
314 /// records itself (the model-step intent and completion), the same clock
315 /// edge a [`Runtime`] would use, so deterministic tests still compare logs.
316 #[must_use]
317 pub fn now(&self) -> OffsetDateTime {
318 match &self.inner.hooks {
319 Some((clock, _)) => clock(),
320 None => OffsetDateTime::now_utc(),
321 }
322 }
323
324 /// A fresh runtime over the shared store, with this state's clock and
325 /// random source.
326 #[must_use]
327 pub fn runtime(&self) -> Runtime {
328 match &self.inner.hooks {
329 Some((clock, random)) => {
330 Runtime::with_hooks(self.inner.store.clone(), clock.clone(), random.clone())
331 }
332 None => Runtime::new(self.inner.store.clone()),
333 }
334 }
335
336 /// Builds a live agent from a submitted definition, through the factory.
337 ///
338 /// # Errors
339 ///
340 /// The factory's human message when the definition will not build.
341 pub async fn build_agent(&self, definition: AgentDefinition) -> Result<BuiltAgent, String> {
342 (self.inner.factory)(definition).await
343 }
344
345 /// Records a registered agent under its content hash, returning that hash.
346 pub fn register_agent(&self, registered: RegisteredAgent) -> String {
347 let hash = registered.agent_hash.clone();
348 self.inner
349 .agents
350 .lock()
351 .expect("agents registry lock")
352 .insert(hash.clone(), registered);
353 hash
354 }
355
356 /// The definition registered under `hash`, if any.
357 #[must_use]
358 pub fn agent(&self, hash: &str) -> Option<RegisteredAgent> {
359 self.inner
360 .agents
361 .lock()
362 .expect("agents registry lock")
363 .get(hash)
364 .cloned()
365 }
366
367 /// Every registered agent's hash, sorted for a stable listing.
368 #[must_use]
369 pub fn agent_hashes(&self) -> Vec<String> {
370 let mut hashes: Vec<String> = self
371 .inner
372 .agents
373 .lock()
374 .expect("agents registry lock")
375 .keys()
376 .cloned()
377 .collect();
378 hashes.sort();
379 hashes
380 }
381
382 /// Records a validated graph document under `hash`, returning whether it was
383 /// newly stored (`true`) or already present (`false`). Re-storing the
384 /// identical document is idempotent: the second call keeps the first and
385 /// reports `false`, the graph counterpart of an agent register's `created`.
386 pub fn store_graph(&self, hash: String, graph: Graph) -> bool {
387 let mut graphs = self.inner.graphs.lock().expect("graphs registry lock");
388 if graphs.contains_key(&hash) {
389 return false;
390 }
391 graphs.insert(hash, graph);
392 true
393 }
394
395 /// The graph document stored under `hash`, if any. `None` is the
396 /// `unknown_graph` case.
397 #[must_use]
398 pub fn graph(&self, hash: &str) -> Option<Graph> {
399 self.inner
400 .graphs
401 .lock()
402 .expect("graphs registry lock")
403 .get(hash)
404 .cloned()
405 }
406
407 /// Every stored graph's hash, sorted for a stable listing.
408 #[must_use]
409 pub fn graph_hashes(&self) -> Vec<String> {
410 let mut hashes: Vec<String> = self
411 .inner
412 .graphs
413 .lock()
414 .expect("graphs registry lock")
415 .keys()
416 .cloned()
417 .collect();
418 hashes.sort();
419 hashes
420 }
421
422 /// Builds a per-run [`RunCtx`] over `log`, with this state's clock and random
423 /// source, so the graph engine can drive a run through the same durability
424 /// substrate the built-in loop uses. This is the graph counterpart of
425 /// [`runtime`](Self::runtime): the built-in loop reaches the store through a
426 /// [`Runtime`]; the graph engine reaches it through a `RunCtx` it drives
427 /// directly, and both share the exact clock/random hooks so a deterministic
428 /// test's logs still compare equal.
429 ///
430 /// # Errors
431 ///
432 /// [`RuntimeError::Replay`] when `log` is not a well-formed run history.
433 pub fn run_ctx(&self, run_id: RunId, log: Vec<EventEnvelope>) -> Result<RunCtx, RuntimeError> {
434 match &self.inner.hooks {
435 Some((clock, random)) => RunCtx::with_hooks(
436 self.inner.store.clone(),
437 run_id,
438 log,
439 clock.clone(),
440 random.clone(),
441 ),
442 None => RunCtx::new(self.inner.store.clone(), run_id, log),
443 }
444 }
445
446 /// Marks a run as being driven. Call this synchronously before spawning
447 /// the driver task, so a stream opened at the same instant sees the run as
448 /// active rather than racing the task's first store write.
449 pub fn begin_run(&self, run_id: RunId) {
450 self.inner
451 .active
452 .lock()
453 .expect("active runs lock")
454 .insert(run_id);
455 }
456
457 /// Records the driver task's handle, for aborting at shutdown.
458 pub fn set_handle(&self, run_id: RunId, handle: JoinHandle<()>) {
459 self.inner
460 .handles
461 .lock()
462 .expect("handles lock")
463 .insert(run_id, handle);
464 }
465
466 /// Marks a run's drive as ended and drops its handle. The task calls this
467 /// as its last act, whether it completed, parked, or errored.
468 pub fn end_run(&self, run_id: RunId) {
469 self.inner
470 .active
471 .lock()
472 .expect("active runs lock")
473 .remove(&run_id);
474 self.inner
475 .handles
476 .lock()
477 .expect("handles lock")
478 .remove(&run_id);
479 }
480
481 /// Whether a run is still being driven by a task in this process.
482 #[must_use]
483 pub fn is_run_active(&self, run_id: RunId) -> bool {
484 self.inner
485 .active
486 .lock()
487 .expect("active runs lock")
488 .contains(&run_id)
489 }
490
491 /// Records (or re-leases) a client-driven run, returning a fresh drive
492 /// token. Called by the open endpoint both for a new run and for a
493 /// re-open, so a resuming tab always receives a current lease and any
494 /// earlier lease is superseded (the single-writer rule from Q5).
495 pub fn lease_client_run(&self, run_id: RunId, record_prompts: bool) -> String {
496 let drive_token = format!("dt_{}", uuid::Uuid::new_v4().simple());
497 self.inner
498 .client_runs
499 .lock()
500 .expect("client runs lock")
501 .insert(
502 run_id,
503 ClientRunLease {
504 drive_token: drive_token.clone(),
505 record_prompts,
506 last_seen: self.now(),
507 },
508 );
509 drive_token
510 }
511
512 /// Refreshes a client-driven run's `last_seen` to now, the driver's proof of
513 /// life. Called by the lease gate on every guarded operation (the driver
514 /// presented its token, so it is alive); a no-op for a run this process holds
515 /// no lease for.
516 pub fn touch_client_run(&self, run_id: RunId) {
517 let now = self.now();
518 if let Some(lease) = self
519 .inner
520 .client_runs
521 .lock()
522 .expect("client runs lock")
523 .get_mut(&run_id)
524 {
525 lease.last_seen = now;
526 }
527 }
528
529 /// Whether a live driver is currently attached to a client-driven run: this
530 /// process holds a lease for it AND the driver presented its token within the
531 /// lease TTL. A lapsed lease (the tab closed, the SDK exited) reports `false`
532 /// — the client-driven half of the liveness evidence `GET /v1/runs` carries.
533 #[must_use]
534 pub fn client_run_driver_live(&self, run_id: RunId) -> bool {
535 let now = self.now();
536 let leases = self.inner.client_runs.lock().expect("client runs lock");
537 match leases.get(&run_id) {
538 Some(lease) => (now - lease.last_seen).unsigned_abs() < self.inner.client_lease_ttl,
539 None => false,
540 }
541 }
542
543 /// The lease for a client-driven run, if this process opened one under
544 /// `run_id`.
545 #[must_use]
546 pub fn client_run(&self, run_id: RunId) -> Option<ClientRunLease> {
547 self.inner
548 .client_runs
549 .lock()
550 .expect("client runs lock")
551 .get(&run_id)
552 .cloned()
553 }
554
555 /// Whether `run_id` names a client-driven run this process opened.
556 #[must_use]
557 pub fn is_client_run(&self, run_id: RunId) -> bool {
558 self.inner
559 .client_runs
560 .lock()
561 .expect("client runs lock")
562 .contains_key(&run_id)
563 }
564
565 /// Aborts every in-flight driver task. Durability is unaffected: each event
566 /// was persisted before the task moved on, so an aborted run is recoverable
567 /// exactly as after a `kill -9`.
568 pub fn abort_all(&self) {
569 let mut handles = self.inner.handles.lock().expect("handles lock");
570 for (_, handle) in handles.drain() {
571 handle.abort();
572 }
573 self.inner.active.lock().expect("active runs lock").clear();
574 }
575}