Skip to main content

basis_host/
source.rs

1//! What basis's own sessions are built on: one runtime for the server process,
2//! one workspace for each directory a client asks about.
3//!
4//! ADR-0018's host shape. The original ACP source used to open a
5//! whole workspace per
6//! session, minting one run from it and dropping it — so
7//! a server holding N editor sessions held N mentra runtimes, N provider
8//! resolutions and N store handles, and no session outlived the workspace
9//! whose MCP connections and hooks it was supposed to be running with. What is
10//! here instead is the two lifetimes the ADR named: the runtime belongs to the
11//! process, the workspace belongs to the repository, and a session is minted
12//! from both.
13//!
14//! # Built late, and once
15//!
16//! Both are built on the first session that needs them, not at
17//! a server is constructed. Neither cell remembers a failure: a request that
18//! failed for want of a credential must fail the same way next time rather
19//! than be answered out of a poisoned cache.
20//!
21//! # What a cached workspace holds open
22//!
23//! Its MCP connections and its hook registrations on the runtime, both of
24//! which have to outlive every session minted from it, and — unless the
25//! template turned it off — a watch on its A2A files, so an agent the
26//! operator adds reaches sessions already running (ADR-0031 §4). A workspace
27//! dropped while a session still runs takes that session's servers and its
28//! `.basis/hooks.json` with it. Nothing here evicts, therefore: `session/close`
29//! reaches basis after the fact, and a turn still unwinding would be the one
30//! paying for the tidiness. The bound is the number of distinct directories one
31//! server is asked about, which is the number of repositories a person has
32//! open.
33
34use std::{
35    collections::{BTreeMap, HashMap},
36    hash::{DefaultHasher, Hash, Hasher},
37    path::{Path, PathBuf},
38    sync::{Arc, Mutex},
39};
40
41use basis::{
42    A2aWatch, McpConfig, McpServer, McpSseServerConfig, McpStreamableHttpServerConfig,
43    PersistedSession, PreparedRun, RunError, RunSpec, Runtime, RuntimeBuilder, Workspace,
44    WorkspaceBuilder,
45    mcp::{McpSseLimits, McpStreamableHttpLimits, SecretString},
46};
47use tokio::sync::OnceCell;
48
49use crate::{SessionSource, SessionTemplate};
50
51/// The default source: sessions basis builds itself, on the process's runtime.
52pub struct ConfiguredSource {
53    /// What the operator said and a client cannot: which model and endpoint,
54    /// whether commands are granted, the product's voice. No workspace lives
55    /// here — every session brings its own `cwd`.
56    template: SessionTemplate,
57    /// One per process, however many sessions and workspaces it carries.
58    runtime: OnceCell<Arc<Runtime>>,
59    /// One per [`WorkspaceKey`], each behind its own cell.
60    ///
61    /// A sync lock on the map and a cell per entry, rather than one async lock
62    /// around the whole thing: opening a workspace spawns MCP servers and may
63    /// resolve a model over the network, and two clients on two repositories
64    /// must not queue behind each other for that. The map lock is only ever
65    /// held long enough to look one entry up.
66    workspaces: Mutex<HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>>,
67}
68
69/// One cached workspace, and the watch on its A2A files that lives exactly as
70/// long as it is cached.
71struct Opened {
72    workspace: Arc<Workspace>,
73    /// Held for its `Drop`, which stops the poll.
74    _a2a_watch: Option<A2aWatch>,
75}
76
77impl ConfiguredSource {
78    pub fn new(template: Option<SessionTemplate>) -> Self {
79        Self {
80            template: template.unwrap_or_default(),
81            runtime: OnceCell::new(),
82            workspaces: Mutex::new(HashMap::new()),
83        }
84    }
85
86    /// The same source over a runtime the caller already built.
87    ///
88    /// Test-only because building one resolves a provider credential, which is
89    /// exactly what an offline test cannot do and exactly what
90    /// [`RuntimeBuilder::with_api_key`](basis::RuntimeBuilder::with_api_key)
91    /// answers — and it is not on [`SessionTemplate`], which is the only thing
92    /// a configured source takes. A host that wants to supply its own runtime
93    /// supplies its own [`SessionSource`], which is what that seam is for.
94    #[cfg(test)]
95    pub fn on_runtime(runtime: Arc<Runtime>, template: Option<SessionTemplate>) -> Self {
96        let source = Self::new(template);
97        source
98            .runtime
99            .set(runtime)
100            .unwrap_or_else(|_| unreachable!("a runtime that was just constructed is empty"));
101        source
102    }
103
104    /// Builds the two halves of one session, in the client's working
105    /// directory: the workspace to open and the per-run spec to mint on it.
106    ///
107    /// Nothing here says anything about approval. A runtime's authorizer is
108    /// fixed for its life, so basis installs one that surfaces every
109    /// consequential call and answers none of them; which of those the client
110    /// actually sees is the session's mode, which can still change (see
111    /// [`SessionApproval`](crate::SessionApproval)).
112    fn parts_for(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> (WorkspaceBuilder, RunSpec) {
113        let template = &self.template;
114
115        let mut builder = Workspace::builder(cwd).with_shell(template.shell);
116        if let Some(model) = &template.model {
117            builder = builder.with_model(model.clone());
118        }
119        if let Some(system_prompt) = &template.system_prompt {
120            builder = builder.with_system_prompt(system_prompt.clone());
121        }
122        // Discovery is the template's promise (`SessionTemplate::with_discovery`)
123        // — the operator's to point, defaulting to basis's own roots.
124        let discovery = &template.discovery;
125        let builder = builder
126            .with_context(discovery.context.clone())
127            .with_skills(discovery.skills.clone())
128            .with_templates(discovery.templates.clone())
129            .with_hooks(discovery.hooks.clone())
130            .with_tools(discovery.tools.clone());
131
132        // The client's servers outrank the workspace's own: it is answering
133        // for this session in particular. Discovery still runs, so a
134        // `.mcp.json` the client said nothing about is still honored.
135        let builder = builder.with_mcp(self.session_mcp(mcp));
136
137        let mut spec = RunSpec::default();
138        if let Some(session_name) = &template.session_name {
139            spec = spec.with_session_name(session_name.clone());
140        }
141        if let Some(effort) = template.effort {
142            spec = spec.with_effort(effort);
143        }
144
145        (builder, spec)
146    }
147
148    /// One session's MCP config: where discovery starts, with the client's
149    /// servers landing on top — they outrank the workspace's own, because the
150    /// client is answering for this session in particular.
151    fn session_mcp(&self, mcp: Vec<McpServer>) -> McpConfig {
152        self.template.discovery.mcp.clone().with_supplied(mcp)
153    }
154
155    /// The workspace this session is minted from, and the per-run half that
156    /// mints it.
157    async fn workspace_for(
158        &self,
159        cwd: PathBuf,
160        mcp: Vec<McpServer>,
161    ) -> Result<(Arc<Workspace>, RunSpec), RunError> {
162        let key = WorkspaceKey::new(&cwd, &mcp);
163        let (builder, spec) = self.parts_for(cwd, mcp);
164
165        Ok((self.open(key, builder).await?, spec))
166    }
167
168    /// The workspace for `key`, opening it if this is the first session to ask.
169    async fn open(
170        &self,
171        key: WorkspaceKey,
172        builder: WorkspaceBuilder,
173    ) -> Result<Arc<Workspace>, RunError> {
174        let cell = Arc::clone(self.lock().entry(key).or_default());
175
176        // Concurrent openers of one key wait on this cell and share its result;
177        // openers of different keys never meet. A failed open leaves the cell
178        // empty, so the next session tries again rather than inheriting a
179        // failure that may have been about the network.
180        let opened = cell
181            .get_or_try_init(|| async move {
182                let runtime = Arc::clone(self.runtime().await?);
183                let workspace = Arc::new(builder.with_runtime(runtime).open().await?);
184                let a2a_watch = self.template.a2a_watch_interval().and_then(|interval| {
185                    match workspace.watch_a2a(interval) {
186                        Ok(watch) => Some(watch),
187                        // Opened inside the runtime the server runs on, so
188                        // this is a host wired unusually: say so and serve on.
189                        Err(error) => {
190                            eprintln!("Warning: {error}; A2A files will not be watched");
191                            None
192                        }
193                    }
194                });
195
196                Ok::<_, RunError>(Opened {
197                    workspace,
198                    _a2a_watch: a2a_watch,
199                })
200            })
201            .await?;
202
203        Ok(Arc::clone(&opened.workspace))
204    }
205
206    /// The process's runtime, built on the first session that needs one.
207    async fn runtime(&self) -> Result<&Arc<Runtime>, RunError> {
208        self.runtime
209            .get_or_try_init(|| async { Ok(Arc::new(self.recipe().build()?)) })
210            .await
211    }
212
213    /// The process half of the template: what ADR-0018 moved onto the runtime.
214    ///
215    /// Provider and endpoint seed the private runtime's recipe, plus the model
216    /// as this runtime's *policy*. [`parts_for`](Self::parts_for) restates
217    /// that model per workspace as an override, so the policy is belt to its
218    /// braces — but a runtime that reported a model none of its workspaces use
219    /// would be describing a process nobody is running.
220    fn recipe(&self) -> RuntimeBuilder {
221        let template = &self.template;
222
223        let mut recipe = Runtime::builder();
224        if let Some(model) = &template.model {
225            recipe = recipe.with_model(model.clone());
226        }
227        if let Some(provider) = template.provider {
228            recipe = recipe.with_provider(provider);
229        }
230        if let Some(base_url) = &template.base_url {
231            recipe = recipe.with_base_url(base_url.clone());
232        }
233
234        recipe
235    }
236
237    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>> {
238        // Poisoned means some other task panicked mid-insert. The map is still
239        // structurally sound, and refusing every later session over one panic
240        // would turn it into a dead connection — the same ruling the session
241        // registry makes.
242        self.workspaces
243            .lock()
244            .unwrap_or_else(|poisoned| poisoned.into_inner())
245    }
246
247    /// The workspaces this source has open, for the acceptance test that one
248    /// directory is one workspace however many sessions were minted on it.
249    #[cfg(test)]
250    pub fn opened(&self) -> Vec<Arc<Workspace>> {
251        self.lock()
252            .values()
253            .filter_map(|cell| cell.get().map(|opened| Arc::clone(&opened.workspace)))
254            .collect()
255    }
256}
257
258#[async_trait::async_trait]
259impl SessionSource for ConfiguredSource {
260    async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError> {
261        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
262
263        workspace.prepare(spec)
264    }
265
266    /// The workspace comes from the client's `cwd` and the conversation from
267    /// an id the client kept, and nothing here pairs them: a client is free to
268    /// send a `cwd` that has nothing to do with the session it names.
269    /// `Workspace::resume` is what refuses that pairing — it compares the
270    /// persisted agent's own base directory against the workspace's identity
271    /// and answers `RunError::WorkspaceMismatch` — which is where the check
272    /// belongs, since a resume states *that* workspace's policy and tool
273    /// audience onto whatever it picks up.
274    async fn resume(
275        &self,
276        agent_id: &str,
277        cwd: PathBuf,
278        mcp: Vec<McpServer>,
279    ) -> Result<PreparedRun, RunError> {
280        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
281
282        workspace.resume(agent_id, spec)
283    }
284
285    fn lists_sessions(&self) -> bool {
286        true
287    }
288
289    /// Reads mentra's store directly. Building a session to enumerate sessions
290    /// would resolve a model over the network to answer a question about files
291    /// on disk.
292    ///
293    /// This depends on a conversation being tagged with
294    /// [`store::runtime_identifier`](basis::store::runtime_identifier) for its
295    /// workspace, and every session opened here is: `Runtime::mint` states the
296    /// identifier per session, so the shared runtime this source runs on files
297    /// each `session/new` under the `cwd` it was opened for.
298    ///
299    /// A conversation that has been *resumed* and run again stays on this
300    /// list too: mentra 0.27 retains a persisted agent's own stored runtime
301    /// identifier through every later save ([`basis::store`] has the account),
302    /// so a row this source's shared runtime resumed re-files under this
303    /// workspace's own tag rather than the runtime's.
304    async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
305        basis::store::list(&cwd)
306    }
307
308    fn deletes_sessions(&self) -> bool {
309        true
310    }
311
312    /// Writes to mentra's store directly, for the reason
313    /// [`list_sessions`](Self::list_sessions) reads it directly: opening a
314    /// workspace to delete a record would resolve a model over the network to
315    /// answer a question about files on disk — and would fail for want of a
316    /// credential on a connection that has done nothing but list.
317    async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
318        basis::store::forget(agent_id)
319    }
320}
321
322/// What makes two sessions the same workspace.
323///
324/// The directory is ADR-0018's key and the obvious half. The servers are the
325/// other half: `mcpServers` arrives per session, and it is the client
326/// answering for *this session in particular*, so two sessions on one
327/// repository need not answer alike. Keying on the directory alone would hand
328/// the second session the first one's servers and silently drop its own —
329/// because a session that came up without its servers looks exactly like one
330/// whose servers had nothing to offer.
331///
332/// Two workspaces on one directory is the cost, and it is bounded: they differ
333/// only in their supplied servers, so they discover the same hooks and carry
334/// the same command posture.
335///
336/// That equality is load-bearing now, and worth stating. A workspace registers
337/// its interception chain live, for its own tool audience, and two opens of one
338/// directory share that audience — so basis counts holders of one chain rather
339/// than registering two, and refuses a same-root open whose chain differs
340/// (`RunError::WorkspaceGuardConflict`). Nothing *this source decides* can
341/// provoke that refusal: every session's hooks come from one `SessionTemplate`'s
342/// discovery configuration, which does not vary per session, and the servers a
343/// key varies on are not part of a chain at all. What the configuration points
344/// at is another matter — the chain is read off disk at each open, so editing
345/// `.basis/hooks.json` between two opens of one directory *does* refuse the
346/// second, and the refusal names the directory. That is the honest boundary:
347/// this source never asks for two chains, and cannot promise a repository
348/// stopped changing under it.
349///
350/// What two live opens of one directory cost is what they always cost — a
351/// second set of MCP connections and a second discovery — and not a second pass
352/// through the repository's hook programs on every call.
353#[derive(Debug, PartialEq, Eq, Hash)]
354struct WorkspaceKey {
355    workspace: PathBuf,
356    /// A digest, so a `{:?}` of this key repeats nothing a client configured.
357    supplied: u64,
358}
359
360impl WorkspaceKey {
361    fn new(cwd: &Path, supplied: &[McpServer]) -> Self {
362        Self {
363            // Canonicalized so a symlinked spelling and its target are one
364            // workspace rather than two, and used as written when it does not
365            // resolve — the same ruling as `store::runtime_identifier`, for the
366            // same reason: keying a map is not the place to validate a path.
367            workspace: std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
368            supplied: digest(supplied),
369        }
370    }
371}
372
373/// A digest of the servers a client supplied, values included.
374///
375/// Everything that decides *what runs with what authority* is hashed: two
376/// sessions that named one server with different commands, or one command with
377/// a different token, are not asking for the same workspace. A digest rather
378/// than the values because this is a map key that outlives the request, and
379/// nothing a client configured should be held here in the clear — the same
380/// line [`McpServer`]'s own `Debug` draws.
381fn digest(servers: &[McpServer]) -> u64 {
382    let mut hasher = DefaultHasher::new();
383
384    for server in servers {
385        match server {
386            McpServer::Stdio(config) => {
387                "stdio".hash(&mut hasher);
388                config.name.hash(&mut hasher);
389                config.command.hash(&mut hasher);
390                config.args.hash(&mut hasher);
391                config.cwd.hash(&mut hasher);
392
393                // A `HashMap` has no order of its own, and a key that depended
394                // on one would differ between two identical requests.
395                let mut env: Vec<(&String, &String)> = config.env.iter().collect();
396                env.sort_unstable();
397                env.hash(&mut hasher);
398            }
399            // Both remote arms destructure exhaustively — config and limits —
400            // so a field mentra adds upstream is a compile error here rather
401            // than a value two different sessions silently share a workspace
402            // over. The discriminant is hashed like the values: two servers
403            // sharing a name and URL but not a transport are not asking for
404            // the same workspace.
405            McpServer::Sse(config) => {
406                let McpSseServerConfig {
407                    name,
408                    url,
409                    headers,
410                    allow_plaintext_credentials,
411                    limits,
412                } = config;
413                hash_remote(
414                    &mut hasher,
415                    "sse",
416                    name,
417                    url,
418                    headers,
419                    *allow_plaintext_credentials,
420                );
421
422                let McpSseLimits {
423                    connect_timeout,
424                    initialize_timeout,
425                    list_tools_timeout,
426                    call_tool_timeout,
427                    stream_idle_timeout,
428                    max_event_bytes,
429                    max_endpoint_bytes,
430                    max_tool_pages,
431                    max_tools,
432                } = limits;
433                (
434                    connect_timeout,
435                    initialize_timeout,
436                    list_tools_timeout,
437                    call_tool_timeout,
438                    stream_idle_timeout,
439                )
440                    .hash(&mut hasher);
441                (
442                    max_event_bytes,
443                    max_endpoint_bytes,
444                    max_tool_pages,
445                    max_tools,
446                )
447                    .hash(&mut hasher);
448            }
449            McpServer::Http(config) => {
450                let McpStreamableHttpServerConfig {
451                    name,
452                    url,
453                    headers,
454                    allow_plaintext_credentials,
455                    limits,
456                } = config;
457                hash_remote(
458                    &mut hasher,
459                    "http",
460                    name,
461                    url,
462                    headers,
463                    *allow_plaintext_credentials,
464                );
465
466                let McpStreamableHttpLimits {
467                    connect_timeout,
468                    initialize_timeout,
469                    list_tools_timeout,
470                    call_tool_timeout,
471                    stream_idle_timeout,
472                    max_event_bytes,
473                    max_response_bytes,
474                    max_tool_pages,
475                    max_tools,
476                } = limits;
477                (
478                    connect_timeout,
479                    initialize_timeout,
480                    list_tools_timeout,
481                    call_tool_timeout,
482                    stream_idle_timeout,
483                )
484                    .hash(&mut hasher);
485                (
486                    max_event_bytes,
487                    max_response_bytes,
488                    max_tool_pages,
489                    max_tools,
490                )
491                    .hash(&mut hasher);
492            }
493            // A transport this build does not know — the enum is
494            // `#[non_exhaustive]`. The discriminant separates two unknown
495            // *variants*; the name separates servers within one. What neither
496            // separates — two unknown servers of one variant differing only
497            // below the name — will key one workspace until this match learns
498            // the variant, and the consequence is bounded: the second session
499            // inherits the first one's connections rather than being handed a
500            // credential. basis's own same-crate matches (`McpServer::name`,
501            // its hand-written `Debug`) fail to compile the moment the
502            // variant lands, which is what forces this arm to become a real
503            // one.
504            server => {
505                std::mem::discriminant(server).hash(&mut hasher);
506                "unknown-transport".hash(&mut hasher);
507                server.name().hash(&mut hasher);
508            }
509        }
510    }
511
512    hasher.finish()
513}
514
515/// The remote transports' shared shape: discriminant tag, name, URL, ordered
516/// headers with their secrets fed into the hash, and the plaintext override —
517/// which decides where a credential may travel, so two configs differing only
518/// there are not the same authority.
519fn hash_remote(
520    hasher: &mut DefaultHasher,
521    transport: &str,
522    name: &str,
523    url: &str,
524    headers: &BTreeMap<String, SecretString>,
525    allow_plaintext_credentials: bool,
526) {
527    transport.hash(hasher);
528    name.hash(hasher);
529    url.hash(hasher);
530    // Already ordered: mentra types these as a `BTreeMap`.
531    for (header, value) in headers {
532        header.hash(hasher);
533        value.expose_secret().hash(hasher);
534    }
535    allow_plaintext_credentials.hash(hasher);
536}
537
538#[cfg(test)]
539mod tests;