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