Skip to main content

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}