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::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}